@azwebmaster/dependency-optimizer
Version:
Scan for unused dependencies and node_modules waste
310 lines • 14.3 kB
JavaScript
import * as fs from 'fs/promises';
import * as path from 'path';
import createDebug from 'debug';
const debug = createDebug('depoptimize:analyzer');
export class NodeModulesAnalyzer {
options;
constructor(options = {}) {
this.options = options;
}
async analyze(projectPath = process.cwd()) {
debug('Starting analysis for project: %s', projectPath);
const nodeModulesPath = path.join(projectPath, 'node_modules');
const sizeThreshold = (this.options.sizeThreshold || 10) * 1024 * 1024; // Convert MB to bytes
const depthThreshold = this.options.depthThreshold || 5;
debug('Configuration - sizeThreshold: %d bytes, depthThreshold: %d', sizeThreshold, depthThreshold);
const result = {
totalPackages: 0,
totalSize: 0,
largePackages: [],
deepPackages: [],
nodeModulesPath
};
try {
// Check if node_modules exists
const nodeModulesExists = await this.directoryExists(nodeModulesPath);
if (!nodeModulesExists) {
debug('node_modules not found at: %s', nodeModulesPath);
return result;
}
debug('Found node_modules at: %s', nodeModulesPath);
// Get all packages in node_modules
const packages = await this.getAllPackages(nodeModulesPath);
result.totalPackages = packages.length;
debug('Found %d packages in node_modules', packages.length);
// Analyze each package
for (const pkg of packages) {
debug('Analyzing package: %s', pkg.name);
const analysis = await this.analyzePackage(pkg.path, pkg.name);
result.totalSize += analysis.size;
// Check if package exceeds size threshold
if (analysis.size > sizeThreshold) {
debug('Large package detected: %s (%d bytes)', pkg.name, analysis.size);
result.largePackages.push(analysis);
}
// Check if package exceeds depth threshold
if (analysis.depth > depthThreshold) {
debug('Deep package detected: %s (depth: %d)', pkg.name, analysis.depth);
result.deepPackages.push(analysis);
}
}
// Sort results by size/depth descending
result.largePackages.sort((a, b) => b.size - a.size);
result.deepPackages.sort((a, b) => b.depth - a.depth);
debug('Analysis complete - total size: %d bytes, large packages: %d, deep packages: %d', result.totalSize, result.largePackages.length, result.deepPackages.length);
}
catch (error) {
// Handle errors gracefully
debug('Error during analysis: %O', error);
console.warn(`Warning: Failed to analyze node_modules: ${error}`);
}
return result;
}
async getAllPackages(nodeModulesPath) {
debug('Scanning for packages in: %s', nodeModulesPath);
const packages = [];
try {
const entries = await fs.readdir(nodeModulesPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() || entry.isSymbolicLink()) {
const entryPath = path.join(nodeModulesPath, entry.name);
// For symlinks, check if they point to directories
if (entry.isSymbolicLink()) {
try {
const stats = await fs.stat(entryPath);
if (!stats.isDirectory()) {
debug('Symlink %s does not point to a directory, skipping', entry.name);
continue;
}
debug('Found symlinked directory: %s', entry.name);
}
catch (error) {
debug('Failed to resolve symlink %s: %O', entry.name, error);
continue;
}
}
if (entry.name.startsWith('@')) {
// Scoped packages
debug('Processing scoped package directory: %s', entry.name);
try {
const scopedEntries = await fs.readdir(entryPath, { withFileTypes: true });
for (const scopedEntry of scopedEntries) {
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
const scopedPath = path.join(entryPath, scopedEntry.name);
// For symlinks in scoped packages, check if they point to directories
if (scopedEntry.isSymbolicLink()) {
try {
const stats = await fs.stat(scopedPath);
if (!stats.isDirectory()) {
debug('Symlink %s/%s does not point to a directory, skipping', entry.name, scopedEntry.name);
continue;
}
debug('Found symlinked scoped package: %s/%s', entry.name, scopedEntry.name);
}
catch (error) {
debug('Failed to resolve symlink %s/%s: %O', entry.name, scopedEntry.name, error);
continue;
}
}
const packageName = `${entry.name}/${scopedEntry.name}`;
// Verify it's a valid package
if (await this.isValidPackage(scopedPath)) {
debug('Found scoped package: %s', packageName);
packages.push({ name: packageName, path: scopedPath });
}
}
}
}
catch (error) {
// Skip if can't read scoped directory
debug('Failed to read scoped directory %s: %O', entry.name, error);
}
}
else {
// Regular packages
if (await this.isValidPackage(entryPath)) {
debug('Found package: %s', entry.name);
packages.push({ name: entry.name, path: entryPath });
}
}
}
}
}
catch (error) {
// Return empty array if can't read node_modules
debug('Failed to read node_modules directory: %O', error);
}
debug('Total packages found: %d', packages.length);
return packages;
}
async isValidPackage(packagePath) {
try {
const packageJsonPath = path.join(packagePath, 'package.json');
await fs.access(packageJsonPath);
return true;
}
catch {
return false;
}
}
async analyzePackage(packagePath, packageName) {
debug('Analyzing package details for: %s', packageName);
const analysis = {
name: packageName,
size: 0,
depth: 0,
path: packagePath
};
try {
// Calculate package size
analysis.size = await this.getDirectorySize(packagePath);
debug('Package %s size: %d bytes', packageName, analysis.size);
// Calculate dependency depth
analysis.depth = await this.getDependencyDepth(packagePath);
debug('Package %s depth: %d', packageName, analysis.depth);
}
catch (error) {
// If we can't analyze, just return default values
debug('Failed to analyze package %s: %O', packageName, error);
}
return analysis;
}
async getDirectorySize(dirPath) {
debug('Calculating size for directory: %s', dirPath);
let size = 0;
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
// Skip nested node_modules to avoid double counting
if (entry.name === 'node_modules') {
debug('Skipping nested node_modules in: %s', dirPath);
continue;
}
size += await this.getDirectorySize(entryPath);
}
else if (entry.isSymbolicLink()) {
try {
const stats = await fs.stat(entryPath);
if (stats.isDirectory()) {
// Skip nested node_modules to avoid double counting
if (entry.name === 'node_modules') {
debug('Skipping symlinked nested node_modules in: %s', dirPath);
continue;
}
debug('Following symlinked directory: %s', entryPath);
size += await this.getDirectorySize(entryPath);
}
else if (stats.isFile()) {
debug('Following symlinked file: %s', entryPath);
size += stats.size;
}
}
catch (error) {
// Skip symlinks we can't resolve
debug('Failed to resolve symlink %s: %O', entryPath, error);
}
}
else if (entry.isFile()) {
try {
const stats = await fs.stat(entryPath);
size += stats.size;
}
catch (error) {
// Skip files we can't read
debug('Failed to read file %s: %O', entryPath, error);
}
}
}
}
catch (error) {
// Return 0 if we can't read the directory
debug('Failed to read directory %s: %O', dirPath, error);
}
debug('Total size for %s: %d bytes', dirPath, size);
return size;
}
async getDependencyDepth(packagePath, visited = new Set()) {
// Prevent infinite recursion
if (visited.has(packagePath)) {
debug('Circular dependency detected, skipping: %s', packagePath);
return 0;
}
visited.add(packagePath);
debug('Calculating depth for: %s', packagePath);
try {
const packageJsonPath = path.join(packagePath, 'package.json');
const packageContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageContent);
const dependencies = {
...packageJson.dependencies,
...packageJson.peerDependencies
};
if (!dependencies || Object.keys(dependencies).length === 0) {
debug('No dependencies found for: %s', packagePath);
return 0;
}
debug('Found %d dependencies for: %s', Object.keys(dependencies).length, packagePath);
let maxDepth = 0;
const nodeModulesPath = path.join(packagePath, 'node_modules');
// Check if this package has its own node_modules
if (await this.directoryExists(nodeModulesPath)) {
debug('Checking nested node_modules: %s', nodeModulesPath);
for (const depName of Object.keys(dependencies)) {
const depPath = await this.findDependencyPath(nodeModulesPath, depName);
if (depPath) {
const depthOfDep = await this.getDependencyDepth(depPath, visited);
maxDepth = Math.max(maxDepth, depthOfDep + 1);
}
}
}
debug('Max depth for %s: %d', packagePath, maxDepth);
return maxDepth;
}
catch (error) {
debug('Failed to calculate depth for %s: %O', packagePath, error);
return 0;
}
}
async findDependencyPath(nodeModulesPath, depName) {
debug('Looking for dependency %s in %s', depName, nodeModulesPath);
// Check for scoped package
if (depName.includes('/')) {
const depPath = path.join(nodeModulesPath, depName);
if (await this.directoryExists(depPath)) {
debug('Found scoped dependency at: %s', depPath);
return depPath;
}
}
else {
const depPath = path.join(nodeModulesPath, depName);
if (await this.directoryExists(depPath)) {
debug('Found dependency at: %s', depPath);
return depPath;
}
}
debug('Dependency %s not found in %s', depName, nodeModulesPath);
return null;
}
async directoryExists(dirPath) {
try {
const stats = await fs.stat(dirPath);
return stats.isDirectory();
}
catch {
return false;
}
}
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)}${units[unitIndex]}`;
}
}
//# sourceMappingURL=analyzer.js.map