depdrift
Version:
A tool to analyze dependency drift in JavaScript projects
277 lines (248 loc) • 10.5 kB
JavaScript
/**
* Core module for dependency assessment
* @module core/assessor
*/
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import axios from 'axios';
/**
* Assess the dependencies of a project
* @param {string} packageJsonPath - Path to the package.json file
* @param {Object} options - Assessment options
* @returns {Promise<Object>} Promise resolving to the assessment results
*/
async function assessDependencies(packageJsonPath, options = {}) {
try {
// Read and parse package.json
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
// Get project details
const projectDir = path.dirname(packageJsonPath);
const projectName = packageJson.name || path.basename(projectDir);
const projectVersion = packageJson.version || '0.0.0';
// Extract dependencies
const dependencies = {};
// Regular dependencies
if (packageJson.dependencies && !options.excludeDep) {
Object.entries(packageJson.dependencies).forEach(([name, version]) => {
dependencies[name] = {
version,
type: 'dependency'
};
});
}
// Dev dependencies
if (packageJson.devDependencies && !options.excludeDev) {
Object.entries(packageJson.devDependencies).forEach(([name, version]) => {
dependencies[name] = {
version,
type: 'devDependency'
};
});
}
// Peer dependencies
if (packageJson.peerDependencies && !options.excludePeer) {
Object.entries(packageJson.peerDependencies).forEach(([name, version]) => {
dependencies[name] = {
version,
type: 'peerDependency'
};
});
}
// Optional dependencies
if (packageJson.optionalDependencies && !options.excludeOptional) {
Object.entries(packageJson.optionalDependencies).forEach(([name, version]) => {
dependencies[name] = {
version,
type: 'optionalDependency'
};
});
}
// Convert to array and fetch latest versions
const dependenciesArray = await Promise.all(
Object.entries(dependencies).map(async ([name, info]) => {
const currentVersion = semver.valid(semver.coerce(info.version)) || info.version;
let latestVersion, publishedAt, daysBehind;
try {
// Fetch package info from npm registry
const response = await axios.get(`https://registry.npmjs.org/${name}`);
const npmData = response.data;
// Get latest version
latestVersion = npmData['dist-tags']?.latest || currentVersion;
// Get publication date of latest version
const timeData = npmData.time || {};
publishedAt = timeData[latestVersion] || new Date().toISOString();
// Calculate days behind
const publishDate = new Date(publishedAt);
const now = new Date();
const diffTime = Math.abs(now - publishDate);
daysBehind = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Calculate drift level
let driftLevel = 'none';
if (semver.valid(currentVersion) && semver.valid(latestVersion)) {
if (semver.eq(currentVersion, latestVersion)) {
driftLevel = 'none';
} else if (semver.major(currentVersion) < semver.major(latestVersion)) {
driftLevel = daysBehind > 180 ? 'critical' : 'high';
} else if (semver.minor(currentVersion) < semver.minor(latestVersion)) {
driftLevel = daysBehind > 30 ? 'medium' : 'low';
} else if (semver.patch(currentVersion) < semver.patch(latestVersion)) {
driftLevel = daysBehind > 14 ? 'low' : 'none';
}
} else {
// If we can't parse versions properly, use days behind as a fallback
if (daysBehind > 180) driftLevel = 'critical';
else if (daysBehind > 90) driftLevel = 'high';
else if (daysBehind > 30) driftLevel = 'medium';
else if (daysBehind > 14) driftLevel = 'low';
else driftLevel = 'none';
}
return {
name,
currentVersion,
latestVersion,
daysBehind,
driftLevel,
isDevDependency: info.type === 'devDependency',
isPeerDependency: info.type === 'peerDependency',
isOptionalDependency: info.type === 'optionalDependency',
type: info.type,
updateStatus: semver.eq(currentVersion, latestVersion) ? 'up-to-date' : 'outdated',
lastPublished: publishedAt
};
} catch (error) {
console.error(`Error fetching data for ${name}: ${error.message}`);
return {
name,
currentVersion,
latestVersion: 'unknown',
daysBehind: 0,
driftLevel: 'unknown',
isDevDependency: info.type === 'devDependency',
isPeerDependency: info.type === 'peerDependency',
isOptionalDependency: info.type === 'optionalDependency',
type: info.type,
updateStatus: 'unknown',
lastPublished: 'unknown'
};
}
})
);
// Build summary
const summary = {
totalDependencies: dependenciesArray.length,
driftLevels: {
none: dependenciesArray.filter(d => d.driftLevel === 'none').length,
low: dependenciesArray.filter(d => d.driftLevel === 'low').length,
medium: dependenciesArray.filter(d => d.driftLevel === 'medium').length,
high: dependenciesArray.filter(d => d.driftLevel === 'high').length,
critical: dependenciesArray.filter(d => d.driftLevel === 'critical').length,
unknown: dependenciesArray.filter(d => d.driftLevel === 'unknown').length
},
averageDaysBehind: dependenciesArray.reduce((sum, dep) => sum + (dep.daysBehind || 0), 0) /
(dependenciesArray.length || 1),
majorVersionsBehind: dependenciesArray.filter(d =>
semver.valid(d.currentVersion) &&
semver.valid(d.latestVersion) &&
semver.major(d.currentVersion) < semver.major(d.latestVersion)
).length,
minorVersionsBehind: dependenciesArray.filter(d =>
semver.valid(d.currentVersion) &&
semver.valid(d.latestVersion) &&
semver.major(d.currentVersion) === semver.major(d.latestVersion) &&
semver.minor(d.currentVersion) < semver.minor(d.latestVersion)
).length,
patchVersionsBehind: dependenciesArray.filter(d =>
semver.valid(d.currentVersion) &&
semver.valid(d.latestVersion) &&
semver.major(d.currentVersion) === semver.major(d.latestVersion) &&
semver.minor(d.currentVersion) === semver.minor(d.latestVersion) &&
semver.patch(d.currentVersion) < semver.patch(d.latestVersion)
).length
};
return {
projectName,
projectVersion,
projectPath: projectDir,
dependencies: dependenciesArray,
summary,
vulnerabilities: [], // Would be populated by security scanner
recommendations: [], // Would be populated by recommendation generator
timestamp: new Date().toISOString() // Add timestamp
};
} catch (error) {
console.error(`Error in assessDependencies: ${error.message}`);
throw error;
}
}
/**
* Generate recommendations based on dependency assessment
* @param {Object} assessment - Assessment results
* @param {Object} options - Recommendation options
* @returns {Array} Array of recommendations
*/
function generateRecommendations(assessment, options = {}) {
const maxRecommendations = options.maxRecommendations || 5;
// Get outdated dependencies sorted by driftLevel priority
const driftPriority = { critical: 0, high: 1, medium: 2, low: 3, none: 4, unknown: 5 };
const outdatedDeps = assessment.dependencies
.filter(dep => dep.driftLevel !== 'none' && dep.driftLevel !== 'unknown')
.sort((a, b) => {
// First by drift level
const driftDiff = driftPriority[a.driftLevel] - driftPriority[b.driftLevel];
if (driftDiff !== 0) return driftDiff;
// Then by days behind
return (b.daysBehind || 0) - (a.daysBehind || 0);
});
// Generate recommendations
const recommendations = outdatedDeps.slice(0, maxRecommendations).map(dep => {
const priorityMap = { critical: 'high', high: 'high', medium: 'medium', low: 'low' };
let reason = '';
if (dep.driftLevel === 'critical') {
reason = `Critical drift: ${dep.daysBehind} days since latest release`;
} else if (dep.driftLevel === 'high') {
reason = `High drift: ${dep.daysBehind} days since latest release`;
} else if (dep.driftLevel === 'medium') {
reason = `Medium drift: ${dep.daysBehind} days since latest release`;
} else {
reason = `Low drift: ${dep.daysBehind} days since latest release`;
}
// Add version information to reason
if (semver.valid(dep.currentVersion) && semver.valid(dep.latestVersion)) {
if (semver.major(dep.currentVersion) < semver.major(dep.latestVersion)) {
reason += " (Major version update needed)";
} else if (semver.minor(dep.currentVersion) < semver.minor(dep.latestVersion)) {
reason += " (Minor version update available)";
} else if (semver.patch(dep.currentVersion) < semver.patch(dep.latestVersion)) {
reason += " (Patch update available)";
}
}
return {
dependencyName: dep.name,
name: dep.name,
currentVersion: dep.currentVersion,
recommendedVersion: dep.latestVersion,
priority: priorityMap[dep.driftLevel] || 'low',
reason: reason,
details: reason
};
});
return recommendations;
}
/**
* Assess dependencies for multiple projects
* @param {Array<string>} projectPaths - Paths to project directories
* @param {Object} options - Assessment options
* @returns {Promise<Array<Object>>} Promise resolving to an array of assessment results
*/
async function batchAssessDependencies(projectPaths, options = {}) {
return Promise.all(
projectPaths.map(path => assessDependencies(path, options))
);
}
export {
assessDependencies,
generateRecommendations,
batchAssessDependencies
};