depdrift
Version:
A tool to analyze dependency drift in JavaScript projects
529 lines (466 loc) • 16.6 kB
JavaScript
/**
* Package analyzer module for evaluating dependency drift and version differences
* @module analyzers/packageAnalyzer
* @description Analyzes package dependencies to determine how far behind latest versions they are,
* calculates drift levels, and provides utilities for version comparison and range parsing.
*/
import path from 'path';
import fs from 'fs-extra';
import { getNpmRegistry } from '../core/registry.js';
import semver from 'semver';
import axios from 'axios';
import { initCache, getCache, setCache } from '../core/cache.js';
import { summarizeDriftLevels } from '../utils/driftUtils.js';
import { exec } from 'child_process';
import { promisify } from 'util';
/**
* Calculate days between two dates
* @param {Date|string} date1 - First date
* @param {Date|string} date2 - Second date
* @returns {number} Number of days between dates
* @private
*/
function daysBetween (date1, date2) {
const d1 = date1 instanceof Date ? date1 : new Date(date1);
const d2 = date2 instanceof Date ? date2 : new Date(date2);
// Convert to UTC to avoid timezone issues
const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
// Calculate days (86400000 ms in a day)
return Math.floor((utc2 - utc1) / 86400000);
}
/**
* Determine the drift level based on days behind and semver difference
* @param {number} daysBehind - Days behind latest version
* @param {string} currentVersion - Current version
* @param {string} latestVersion - Latest version
* @returns {string} Drift level ('none', 'low', 'medium', 'high', 'critical')
* @private
*/
function determineDriftLevel (daysBehind, currentVersion, latestVersion) {
if (daysBehind === 0 || currentVersion === latestVersion) {
return 'none';
}
// Parse versions
const current = semver.parse(currentVersion);
const latest = semver.parse(latestVersion);
if (!current || !latest) {
// If can't parse versions, use days as fallback
return determineDriftLevelByDays(daysBehind);
}
// Prioritize semantic versioning differences over time-based drift
// This ensures a patch update won't be marked as critical just because it's old
// - Major version differences are high/critical based on compatibility risk
// - Minor version differences are medium/high based on feature gap
// - Patch versions are capped at medium severity regardless of time
// Major version difference - highest priority
if (latest.major > current.major) {
// Multiple major versions behind is critical
if (latest.major - current.major >= 2) {
return 'critical';
}
// One major version behind is high, regardless of time
return 'high';
}
// Minor version difference (same major)
if (latest.minor > current.minor) {
// Many minor versions behind is high
if (latest.minor - current.minor >= 5) {
return 'high';
}
// Fewer minor versions are medium, regardless of time
return 'medium';
}
// Patch version difference (same major and minor)
// For patch updates, we'll cap the severity to prevent marking them critical
if (latest.patch > current.patch) {
// Many patches behind should be medium at most
if (latest.patch - current.patch >= 10) {
return 'medium';
}
// For patch updates, consider time but cap at medium
if (daysBehind > 180) {
return 'medium';
} else if (daysBehind > 90) {
return 'low';
}
return 'low';
}
// If we get here, it's likely a prerelease version comparison that's tricky to parse
// Apply time-based calculation but cap at medium for non-major/minor changes
const timeBased = determineDriftLevelByDays(daysBehind);
if (timeBased === 'critical' || timeBased === 'high') {
return 'medium';
}
return timeBased;
}
/**
* Determine drift level based only on days behind
* @param {number} days - Days behind latest version
* @returns {string} Drift level ('none', 'low', 'medium', 'high', 'critical')
* @private
*/
function determineDriftLevelByDays (days) {
if (days === 0) {
return 'none';
}
if (days <= 60) {
return 'low';
}
if (days <= 180) {
return 'medium';
}
if (days <= 365) {
return 'high';
}
return 'critical';
}
/**
* Analyze a single dependency for drift
* @param {string} name - Dependency name
* @param {string} currentVersion - Current version or semver range
* @param {boolean} [dev=false] - Whether this is a devDependency
* @returns {Promise<Object>} Analysis result for the dependency
* @property {string} name - Package name
* @property {string} currentVersion - Current version without range indicators
* @property {string|null} latestVersion - Latest version from registry
* @property {number} daysBehind - Days behind latest version (-1 if unknown)
* @property {string} driftLevel - Calculated drift level ('none', 'low', 'medium', 'high', 'critical', 'unknown')
* @property {string|null} lastPublished - ISO date string of latest version publication
* @property {string|undefined} error - Error message if analysis failed
* @property {boolean} isDevDependency - Whether this is a dev dependency
* @public
*/
async function analyzeDependency (name, currentVersion, dev = false) {
const registry = getNpmRegistry();
let packageInfo;
try {
packageInfo = await registry.getPackageInfo(name);
} catch (error) {
return {
name,
currentVersion,
latestVersion: null,
daysBehind: -1,
driftLevel: 'unknown',
lastPublished: null,
error: error.message,
isDevDependency: dev
};
}
// Get latest version
const latestVersion = packageInfo['dist-tags']?.latest;
if (!latestVersion) {
return {
name,
currentVersion,
latestVersion: null,
daysBehind: -1,
driftLevel: 'unknown',
lastPublished: null,
error: 'No latest version found',
isDevDependency: dev
};
}
// If currentVersion has range indicators (^~), clean it
const cleanCurrentVersion = semver.clean(currentVersion) || currentVersion.replace(/[^\d.]/g, '');
// Last published date from the latest version
const lastPublished = packageInfo.time?.[latestVersion];
const now = new Date();
const daysBehind = lastPublished ? daysBetween(lastPublished, now) : -1;
// Determine drift level
const driftLevel = determineDriftLevel(daysBehind, cleanCurrentVersion, latestVersion);
return {
name,
currentVersion: cleanCurrentVersion,
latestVersion,
daysBehind,
driftLevel,
lastPublished,
isDevDependency: dev
};
}
/**
* Analyze a package.json for dependency drift
* @param {Object} packageInfo - The parsed package.json content
* @param {Object} [options={}] - Analysis options
* @param {boolean} [options.groupByLevel=false] - Group results by drift level
* @param {boolean} [options.excludeProd=false] - Exclude production dependencies
* @param {boolean} [options.excludeDev=false] - Exclude dev dependencies
* @param {boolean} [options.excludePeer=false] - Exclude peer dependencies
* @param {boolean} [options.excludeOptional=false] - Exclude optional dependencies
* @param {number} [options.maxConcurrent=10] - Maximum concurrent requests
* @returns {Promise<Object>} Analysis results
* @property {Array<Object>} dependencies - List of analyzed dependencies
* @property {Object} summary - Summary statistics of drift levels
* @public
*/
async function analyzePackage (packageInfo, options = {}) {
const {
groupByLevel = false,
excludeProd = false,
excludeDev = false,
excludePeer = false,
excludeOptional = false,
maxConcurrent = 10
} = options;
// Extract dependencies
const prodDeps = excludeProd ? {} : (packageInfo.dependencies || {});
const devDeps = excludeDev ? {} : (packageInfo.devDependencies || {});
const peerDeps = excludePeer ? {} : (packageInfo.peerDependencies || {});
const optionalDeps = excludeOptional ? {} : (packageInfo.optionalDependencies || {});
// Combine dependencies
const dependencies = [];
// Process production dependencies
for (const [name, version] of Object.entries(prodDeps)) {
// Skip if it's also in optionalDependencies
if (name in optionalDeps) continue;
dependencies.push({
name,
currentVersion: version.replace(/^[\^~]/, ''),
isDevDependency: false,
isOptionalDependency: false,
isPeerDependency: false,
type: 'regular'
});
}
// Process optional dependencies
for (const [name, version] of Object.entries(optionalDeps)) {
dependencies.push({
name,
currentVersion: version.replace(/^[\^~]/, ''),
isDevDependency: false,
isOptionalDependency: true,
isPeerDependency: false,
type: 'optional'
});
}
// Process dev dependencies
for (const [name, version] of Object.entries(devDeps)) {
dependencies.push({
name,
currentVersion: version.replace(/^[\^~]/, ''),
isDevDependency: true,
isOptionalDependency: false,
isPeerDependency: false,
type: 'dev'
});
}
// Process peer dependencies
for (const [name, version] of Object.entries(peerDeps)) {
dependencies.push({
name,
currentVersion: version.replace(/^[\^~]/, ''),
isDevDependency: false,
isOptionalDependency: false,
isPeerDependency: true,
type: 'peer'
});
}
// Process in batches to avoid hammering the npm registry
const batchSize = maxConcurrent;
const batches = [];
for (let i = 0; i < dependencies.length; i += batchSize) {
batches.push(dependencies.slice(i, i + batchSize));
}
const results = [];
// Process batches sequentially
for (const batch of batches) {
const batchResults = await Promise.all(batch.map(async (dep) => {
try {
const npmInfo = await fetchNpmPackageInfo(dep.name);
if (!npmInfo) {
return {
...dep,
error: 'Failed to fetch package info',
driftLevel: 'unknown',
daysBehind: -1
};
}
const latestVersion = npmInfo['dist-tags']?.latest;
if (!latestVersion) {
return {
...dep,
error: 'No latest version found',
driftLevel: 'unknown',
daysBehind: -1
};
}
// Clean version (remove range indicators)
const cleanCurrentVersion = semver.clean(dep.currentVersion) ||
dep.currentVersion.replace(/[^\d.]/g, '');
// Get publication date of latest version
const lastPublished = npmInfo.time?.[latestVersion];
const now = new Date();
const daysBehind = lastPublished ? daysBetween(lastPublished, now) : -1;
// Calculate drift level
const driftLevel = calculateDriftLevel(
cleanCurrentVersion,
latestVersion,
daysBehind
);
return {
...dep,
currentVersion: cleanCurrentVersion,
latestVersion,
daysBehind,
driftLevel,
lastPublished
};
} catch (error) {
return {
...dep,
error: error.message,
driftLevel: 'unknown',
daysBehind: -1
};
}
}));
results.push(...batchResults);
}
// Generate summary statistics
const summary = summarizeDriftLevels(results);
// Create the result object
const result = {
dependencies: results,
summary,
timestamp: new Date().toISOString(),
projectName: packageInfo.name || 'Unknown Project',
projectVersion: packageInfo.version || '0.0.0'
};
// Group by level if requested
if (groupByLevel) {
result.byLevel = {
critical: results.filter(d => d.driftLevel === 'critical'),
high: results.filter(d => d.driftLevel === 'high'),
medium: results.filter(d => d.driftLevel === 'medium'),
low: results.filter(d => d.driftLevel === 'low'),
none: results.filter(d => d.driftLevel === 'none'),
unknown: results.filter(d => d.driftLevel === 'unknown'),
'peer-missing': results.filter(d => d.driftLevel === 'peer-missing'),
'optional-missing': results.filter(d => d.driftLevel === 'optional-missing')
};
}
return result;
}
/**
* Fetch npm package information from the registry
* @param {string} packageName - Package name to fetch information for
* @param {Object} [options={}] - Fetch options
* @param {boolean} [options.useCache=true] - Whether to use cached information if available
* @returns {Promise<Object|null>} Package information object or null if not found/error
* @public
*/
async function fetchNpmPackageInfo (packageName, options = {}) {
const registry = getNpmRegistry();
try {
return await registry.getPackageInfo(packageName);
} catch (error) {
console.error(`Error fetching package info for ${packageName}: ${error.message}`);
return null;
}
}
/**
* Parse a version range to get a specific version
* @param {string} versionRange - Version range (e.g., ^1.2.3, ~2.0.0)
* @param {Array<string>} [availableVersions=[]] - Available versions to select from
* @returns {string} Specific version that satisfies the range, or cleaned version string
* @public
* @example
* // Returns "1.2.3" given "^1.2.0" and ["1.0.0", "1.1.0", "1.2.3"]
* parseVersionRange('^1.2.0', ['1.0.0', '1.1.0', '1.2.3']);
*/
function parseVersionRange (versionRange, availableVersions = []) {
// If it's already a specific version, return it
if (semver.valid(versionRange)) {
return versionRange;
}
// If it has a range indicator, find the highest matching version
if (availableVersions.length > 0) {
try {
// Filter out prerelease versions unless the range explicitly includes them
const filteredVersions = versionRange.includes('-')
? availableVersions
: availableVersions.filter(v => !isPreRelease(v));
// Find the highest version that satisfies the range
const highestMatching = semver.maxSatisfying(filteredVersions, versionRange);
if (highestMatching) {
return highestMatching;
}
} catch (error) {
// If semver parsing fails, continue to fallback
}
}
// Fallback: strip range indicators and return
return versionRange.replace(/^[\^~]/, '');
}
/**
* Check if a version is a prerelease version (alpha, beta, rc, etc.)
* @param {string} version - Version to check
* @returns {boolean} True if it's a prerelease version, false otherwise
* @public
*/
function isPreRelease (version) {
if (!version) return false;
const parsed = semver.parse(version);
if (!parsed) return false;
return parsed.prerelease.length > 0;
}
/**
* Check if an installed version satisfies a version range specification
* @param {string} installedVersion - The installed version to check
* @param {string} versionRange - The version range specification (e.g., ^1.0.0, ~2.0.0)
* @returns {boolean} True if the installed version satisfies the range, false otherwise
* @public
* @example
* // Returns true
* satisfiesRange('1.2.3', '^1.0.0');
*
* // Returns false
* satisfiesRange('1.2.3', '^2.0.0');
*/
function satisfiesRange (installedVersion, versionRange) {
try {
// Handle exact versions
if (versionRange === installedVersion) {
return true;
}
// Use semver to check if the installed version satisfies the range
return semver.satisfies(installedVersion, versionRange);
} catch (error) {
// In case of parsing errors, fall back to string comparison
return versionRange.includes(installedVersion);
}
}
async function getPackageVersions(packageName) {
try {
// ... existing code ...
} catch (err) {
console.warn(`Error fetching versions for ${packageName}:`, err);
return [];
}
}
/**
* Calculate the drift level based on version difference and days behind
* @param {string} currentVersion - Current version
* @param {string} latestVersion - Latest version
* @param {number} daysBehind - Days behind latest version
* @returns {string} Drift level ('none', 'low', 'medium', 'high', 'critical')
*/
export function calculateDriftLevel(currentVersion, latestVersion, daysBehind) {
if (currentVersion === latestVersion) return 'none';
if (daysBehind < 30) return 'low';
if (daysBehind < 90) return 'medium';
if (daysBehind < 180) return 'high';
return 'critical';
}
export {
analyzePackage,
analyzeDependency,
fetchNpmPackageInfo,
parseVersionRange,
isPreRelease,
satisfiesRange,
getPackageVersions,
determineDriftLevel
};