UNPKG

depdrift

Version:

A tool to analyze dependency drift in JavaScript projects

410 lines (367 loc) 15.1 kB
/** * Dependency drift analyzer module for analyzing differences between expected and actual dependencies * @module analyzers/driftAnalyzer * @description Analyzes the differences between dependency trees, classifies version drifts, * and provides detailed reports on dependency inconsistencies in Node.js projects. */ import semver from 'semver'; /** * Flattens an Arborist tree into a map of name@version -> node * @private * @param {Object} tree - The Arborist tree to flatten * @param {Object} [options={}] - Flattening options * @param {number} [options.maxDepth=Infinity] - Maximum depth to traverse * @returns {Map<string, Object>} A map of package specifications to nodes * @property {string} name - Package name * @property {string} version - Package version * @property {string|null} expectedRange - Expected semver range if available * @property {string} dependencyType - Type of dependency (dependencies, devDependencies, etc.) */ function flattenTree (tree, options = {}) { const { maxDepth = Infinity } = options; const result = new Map(); const visited = new Set(); // Prevent processing the same node multiple times // Process the root node's dependencies if (!tree.edgesOut) { return result; } // Function to recursively process a node and its dependencies function processNode (node, isRoot = false, depth = 0) { // Skip the root node itself from the results and respect max depth if (depth > maxDepth) { return; } // Skip already visited nodes (prevents infinite recursion in circular deps) const nodeId = node.id || `${node.name}@${node.version}`; if (!isRoot && visited.has(nodeId)) { return; } if (!isRoot) { visited.add(nodeId); // Extract dependency type and expected range from edge if available const { range, type } = getExpectedRangeAndType(node); // Create the key & store essential data (not the whole node to save memory) const key = `${node.name}@${node.version}`; result.set(key, { name: node.name, version: node.version, expectedRange: range, dependencyType: node.dependencyType || type }); } // Process all dependencies recursively if (node.edgesOut) { for (const [, edge] of Object.entries(node.edgesOut)) { if (edge.to) { processNode(edge.to, false, depth + 1); } } } } // Start the recursion from the root processNode(tree, true); return result; } /** * Gets the expected semver range and type for a node from its incoming edges * @private * @param {Object} node - The Arborist node object * @returns {Object} Object containing the expected range and dependency type * @property {string|null} range - The expected semver range or null if not found * @property {string} type - The dependency type (defaults to 'dependencies' if not found) */ function getExpectedRangeAndType (node) { if (!node.edgesIn || node.edgesIn.size === 0) { return { range: null, type: 'dependencies' }; } // Find the first valid edge that points to this node for (const edge of node.edgesIn) { if (edge.from && edge.to && edge.to.name === node.name) { return { range: edge.spec || null, type: edge.type || 'dependencies' }; } } return { range: null, type: 'dependencies' }; } /** * Determines the semantic version difference classification between expected and installed versions * @param {string} expectedRange - The expected semver range from package.json * @param {string} installedVersion - The actual installed version from node_modules * @param {Object} [options={}] - Classification options * @returns {'safe'|'minor'|'major'} Classification of the version difference: * - 'safe': No drift or patch-level drift only * - 'minor': Minor version drift * - 'major': Major version drift or unclassifiable drift * @public */ function classifyVersionDifference (expectedRange, installedVersion, options = {}) { // Validate inputs if (!expectedRange || !installedVersion) { return 'major'; // Default to major difference if inputs are invalid } try { // Clean up versions for comparison const cleanInstalledVersion = semver.clean(installedVersion) || installedVersion; // If installed version satisfies the expected range, it's safe if (semver.satisfies(cleanInstalledVersion, expectedRange, { includePrerelease: true })) { return 'safe'; } // For version comparison, we need a concrete version to compare against // For ranges, find the highest version that would satisfy the range let expectedVersion; // Try to get a real version to compare against if (semver.validRange(expectedRange)) { // Extract a usable version from the range if (expectedRange.startsWith('^') || expectedRange.startsWith('~')) { // For caret/tilde ranges, use the version without the prefix expectedVersion = expectedRange.substring(1); } // For more complex ranges like >=1.0.0 <2.0.0 else if (expectedRange.includes(' ')) { // Try to parse the range and get a base version try { // For complex ranges, we can try to get the max satisfying version // from a set of possible versions based on the installed version const possibleVersions = generateVersionCandidates(cleanInstalledVersion); expectedVersion = semver.maxSatisfying(possibleVersions, expectedRange); if (!expectedVersion) { // If we can't find a satisfying version, use the coerced version expectedVersion = semver.coerce(expectedRange); if (expectedVersion) { expectedVersion = expectedVersion.version; } else { // Last resort - just find the first number in the range const match = expectedRange.match(/\d+\.\d+\.\d+/); expectedVersion = match ? match[0] : null; } } } catch (e) { // If all methods fail, use coerce as a fallback const coercedVersion = semver.coerce(expectedRange); expectedVersion = coercedVersion ? coercedVersion.version : null; } } // Handle simple versions without range specifiers else if (semver.valid(expectedRange)) { expectedVersion = expectedRange; } // For other ranges, coerce to a usable version else { const coercedVersion = semver.coerce(expectedRange); expectedVersion = coercedVersion ? coercedVersion.version : null; } } else if (semver.valid(expectedRange)) { // It's already a valid version, no need to extract expectedVersion = expectedRange; } else { // Last resort, try to coerce it const coercedVersion = semver.coerce(expectedRange); expectedVersion = coercedVersion ? coercedVersion.version : null; } // If we couldn't get a valid expected version, default to major if (!expectedVersion || !semver.valid(expectedVersion)) { return 'major'; } // Compare major/minor/patch numbers if (semver.major(cleanInstalledVersion) !== semver.major(expectedVersion)) { return 'major'; } else if (semver.minor(cleanInstalledVersion) !== semver.minor(expectedVersion)) { return 'minor'; } else { // If major and minor match, it's a patch difference - considered safe return 'safe'; } } catch (error) { console.warn('Error classifying version difference:', error); return 'unknown'; } } /** * Generates an array of version candidates for comparison in semver operations * @private * @param {string} version - Base version to generate candidates around * @returns {Array<string>} Array of version strings to use in maxSatisfying checks */ function generateVersionCandidates (version) { try { const parsed = semver.parse(version); if (!parsed) { return [version]; } const { major, minor, patch } = parsed; const candidates = [`${major}.${minor}.${patch}`]; // Add minor variations for (let m = Math.max(0, minor - 1); m <= minor + 1; m++) { candidates.push(`${major}.${m}.0`); } // Add major variations for (let M = Math.max(0, major - 1); M <= major + 1; M++) { candidates.push(`${M}.0.0`); } return candidates; } catch (error) { console.warn('Error generating version candidates:', error); return []; } } /** * Determines the appropriate status for a missing package based on dependency type * @private * @param {string} dependencyType - The type of dependency (dependencies, devDependencies, etc.) * @returns {'missing'|'optional-missing'|'peer-missing'} Appropriate status for the missing dependency */ function getMissingStatus (dependencyType) { switch (dependencyType) { case 'optionalDependencies': return 'optional-missing'; case 'peerDependencies': return 'peer-missing'; default: return 'missing'; } } /** * Analyzes drift between expected and actual dependency trees * @param {Object} idealTree - The expected dependency tree from package.json/lockfile * @param {Object} actualTree - The actual installed dependency tree from node_modules * @param {Object} [options={}] - Analysis options * @param {number} [options.maxDepth=Infinity] - Maximum depth to traverse in tree * @param {boolean} [options.excludeDevDependencies=false] - Whether to exclude dev dependencies * @param {boolean} [options.groupByDriftType=false] - Whether to group results by drift type * @returns {Object} Analysis results * @property {Array<Object>} results - Array of drift analysis results for each dependency * @property {Object} summary - Summary statistics of drift analysis * @property {number} summary.total - Total number of dependencies analyzed * @property {number} summary.matching - Number of exactly matching dependencies * @property {number} summary.safe - Number of dependencies with safe drift (patch level) * @property {number} summary.minor - Number of dependencies with minor version drift * @property {number} summary.major - Number of dependencies with major version drift * @property {number} summary.missing - Number of missing dependencies * @property {number} summary.extraneous - Number of extraneous dependencies * @property {Object} [grouped] - Dependencies grouped by drift type (if groupByDriftType=true) * @public */ function analyzeDrift (idealTree, actualTree, options = {}) { const { maxDepth = Infinity, excludeDevDependencies = false, groupByDriftType = false } = options; // Flatten both trees for easier comparison const expectedDeps = flattenTree(idealTree, { maxDepth }); const actualDeps = flattenTree(actualTree, { maxDepth }); // Track differences const driftResults = []; // First, check all expected dependencies against actual for (const [key, expectedNode] of expectedDeps.entries()) { // Skip dev dependencies if excluded if (excludeDevDependencies && (expectedNode.dependencyType === 'devDependencies' || expectedNode.dependencyType === 'dev')) { continue; } // Find matching actual dependency by name const actualKey = Array.from(actualDeps.keys()).find(k => k.startsWith(`${expectedNode.name}@`)); if (actualKey) { const actualNode = actualDeps.get(actualKey); // Check for version differences if (actualNode.version !== expectedNode.version) { // Classify the version difference const diffType = classifyVersionDifference( expectedNode.expectedRange || expectedNode.version, actualNode.version ); driftResults.push({ name: expectedNode.name, expectedVersion: expectedNode.version, expectedRange: expectedNode.expectedRange, actualVersion: actualNode.version, dependencyType: expectedNode.dependencyType, status: diffType }); } else { // Versions match exactly, no drift driftResults.push({ name: expectedNode.name, expectedVersion: expectedNode.version, expectedRange: expectedNode.expectedRange, actualVersion: actualNode.version, dependencyType: expectedNode.dependencyType, status: 'matching' }); } } else { // Missing dependency driftResults.push({ name: expectedNode.name, expectedVersion: expectedNode.version, expectedRange: expectedNode.expectedRange, actualVersion: null, dependencyType: expectedNode.dependencyType, status: getMissingStatus(expectedNode.dependencyType) }); } } // Find extraneous dependencies for (const [key, actualNode] of actualDeps.entries()) { const expectedKey = Array.from(expectedDeps.keys()).find(k => k.startsWith(`${actualNode.name}@`)); if (!expectedKey) { driftResults.push({ name: actualNode.name, expectedVersion: null, expectedRange: null, actualVersion: actualNode.version, dependencyType: actualNode.dependencyType, status: 'extraneous' }); } } // Summary statistics const summary = { total: driftResults.length, matching: driftResults.filter(r => r.status === 'matching').length, safe: driftResults.filter(r => r.status === 'safe').length, minor: driftResults.filter(r => r.status === 'minor').length, major: driftResults.filter(r => r.status === 'major').length, missing: driftResults.filter(r => ['missing', 'optional-missing', 'peer-missing'].includes(r.status)).length, extraneous: driftResults.filter(r => r.status === 'extraneous').length }; // Add percentages if (summary.total > 0) { summary.matchingPercent = Math.round((summary.matching / summary.total) * 100); summary.safePercent = Math.round((summary.safe / summary.total) * 100); summary.minorPercent = Math.round((summary.minor / summary.total) * 100); summary.majorPercent = Math.round((summary.major / summary.total) * 100); summary.missingPercent = Math.round((summary.missing / summary.total) * 100); summary.extraneousPercent = Math.round((summary.extraneous / summary.total) * 100); summary.driftPercent = 100 - summary.matchingPercent; } // Return the full results if (groupByDriftType) { return { results: driftResults, summary, grouped: { matching: driftResults.filter(r => r.status === 'matching'), safe: driftResults.filter(r => r.status === 'safe'), minor: driftResults.filter(r => r.status === 'minor'), major: driftResults.filter(r => r.status === 'major'), missing: driftResults.filter(r => ['missing', 'optional-missing', 'peer-missing'].includes(r.status)), extraneous: driftResults.filter(r => r.status === 'extraneous') } }; } return { results: driftResults, summary }; } export { analyzeDrift, flattenTree, classifyVersionDifference };