UNPKG

depsweep

Version:

🌱 Automated intelligent dependency cleanup with environmental impact reporting

500 lines (499 loc) 22.8 kB
#!/usr/bin/env node import * as fs from "node:fs/promises"; import path from "node:path"; import { stdin as input, stdout as output } from "node:process"; import * as readline from "node:readline/promises"; import chalk from "chalk"; import cliProgress from "cli-progress"; import CliTable from "cli-table3"; import { Command } from "commander"; import { isBinaryFileSync } from "isbinaryfile"; import ora from "ora"; import { CLI_STRINGS, FILE_PATTERNS, MESSAGES, PACKAGE_MANAGERS, isProtectedDependency, } from "./constants.js"; import { safeExecSync, detectPackageManager, measurePackageInstallation, getParentPackageDownloads, getYearlyDownloads, calculateImpactStats, displayImpactTable, formatSize, formatTime, formatNumber, calculateEnvironmentalImpact, calculateCumulativeEnvironmentalImpact, displayEnvironmentalImpactTable, generateEnvironmentalRecommendations, displayEnvironmentalHeroMessage, } from "./helpers.js"; import { getSourceFiles, findClosestPackageJson, getDependencies, getPackageContext, getDependencyInfo, } from "./utils.js"; import { PerformanceMonitor, MemoryOptimizer, } from "./performance-optimizations.js"; let activeSpinner = null; let activeProgressBar = null; let activeReadline = null; function cleanup() { if (activeSpinner) { activeSpinner.stop(); } if (activeProgressBar) { activeProgressBar.stop(); } if (activeReadline) { activeReadline.close(); } if (process.env.NODE_ENV !== "test") { process.exit(0); } } function isValidPackageName(name) { return FILE_PATTERNS.PACKAGE_NAME_REGEX.test(name); } function logNewlines(count = 1) { for (let index = 0; index < count; index++) { console.log(); } } export function customSort(a, b) { const aNormalized = a.replace(/^@/, ""); const bNormalized = b.replace(/^@/, ""); return aNormalized.localeCompare(bNormalized, "en", { sensitivity: "base" }); } async function main() { const performanceMonitor = PerformanceMonitor.getInstance(); const memoryOptimizer = MemoryOptimizer.getInstance(); try { performanceMonitor.startTimer("totalExecution"); process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); const packageJsonPath = await findClosestPackageJson(process.cwd()); const projectDirectory = path.dirname(packageJsonPath); const context = await getPackageContext(packageJsonPath); const packageJsonString = (await fs.readFile(packageJsonPath, "utf8")) || "{}"; const packageJson = JSON.parse(packageJsonString); const program = new Command(); program.configureOutput({ writeOut: (string_) => process.stdout.write(string_), writeErr: (string_) => process.stdout.write(string_), }); program.exitOverride(); program .name(CLI_STRINGS.CLI_NAME) .usage("[options]") .description(CLI_STRINGS.CLI_DESCRIPTION) .option("-v, --verbose", "display detailed usage information") .option("-a, --aggressive", "allow removal of protected dependencies") .option("-s, --safe <deps>", "dependencies that will not be removed") .option("-i, --ignore <paths>", "patterns to ignore during scanning") .option("-m, --measure-impact", "measure unused dependency impact") .option("-d, --dry-run", "run without making changes") .option("-n, --no-progress", "disable the progress bar") .version(packageJson.version, "--version", "display installed version") .addHelpText("after", CLI_STRINGS.EXAMPLE_TEXT); program.exitOverride(() => { }); if (process.argv.includes("--help")) { const helpText = program.helpInformation(); process.stdout.write(`${helpText}\n`); process.exit(0); } program.parse(process.argv); const options = program.opts(); if (options.help) { program.outputHelp(); return; } console.log(chalk.cyan(MESSAGES.title)); logNewlines(); console.log(chalk.blue(`Package.json found at: ${packageJsonPath}`)); process.on("uncaughtException", (error) => { console.error(chalk.red(MESSAGES.fatalError), error); process.exit(1); }); process.on("unhandledRejection", (error) => { console.error(chalk.red(MESSAGES.fatalError), error); process.exit(1); }); const dependencies = await getDependencies(packageJsonPath); dependencies.sort(customSort); const allFiles = await getSourceFiles(projectDirectory, options.ignore || []); const filteredFiles = []; for (const file of allFiles) { if (!isBinaryFileSync(file)) { filteredFiles.push(file); } } const sourceFiles = filteredFiles; const topLevelDeps = new Set(dependencies); const safeUnused = []; if (options.safe) { const safeDeps = typeof options.safe === "string" ? options.safe .split(",") .map((dep) => dep.trim()) .filter((dep) => dep.length > 0) : Array.isArray(options.safe) ? options.safe : []; for (const safeDep of safeDeps) { if (!safeUnused.includes(safeDep)) { safeUnused.push(safeDep); } } } const totalAnalysisSteps = dependencies.length * sourceFiles.length; let analysisStepsProcessed = 0; let progressBar = null; if (options.progress) { progressBar = new cliProgress.SingleBar({ format: CLI_STRINGS.PROGRESS_FORMAT, barCompleteChar: CLI_STRINGS.BAR_COMPLETE, barIncompleteChar: CLI_STRINGS.BAR_INCOMPLETE, hideCursor: true, clearOnComplete: false, forceRedraw: true, linewrap: false, }); activeProgressBar = progressBar; progressBar.start(100, 0, { currentDeps: 0, totalDeps: dependencies.length, dep: "", }); } let subdepIndex = 0; let subdepCount = 0; let totalDepsProcessed = 0; const currentDepFiles = new Set(); const subdepsProcessed = new Set(); const progressCallback = (filePath, sIndex, sCount) => { analysisStepsProcessed++; if (progressBar) { progressBar.update((analysisStepsProcessed / totalAnalysisSteps) * 100, { currentDeps: totalDepsProcessed, totalDeps: dependencies.length, dep: currentDependency, }); } }; const depInfoMap = new Map(); let currentDependency = ""; for (const [index, dep] of dependencies.entries()) { currentDependency = dep; subdepsProcessed.clear(); totalDepsProcessed++; currentDepFiles.clear(); subdepIndex = 0; subdepCount = 0; const info = await getDependencyInfo(dep, context, sourceFiles, topLevelDeps, { onProgress: progressCallback, totalAnalysisSteps, }); depInfoMap.set(dep, info); await new Promise((res) => setImmediate(res)); } if (progressBar) { progressBar.update(100, { currentDeps: dependencies.length, totalDeps: dependencies.length, dep: chalk.green("✓"), }); progressBar.stop(); } if (options.verbose) { performanceMonitor.logSummary(); const memoryStats = memoryOptimizer.getMemoryStats(); console.log(chalk.blue(`\nMemory Usage: ${Math.round(memoryStats.heapUsed / 1024 / 1024)}MB / ${Math.round(memoryStats.heapTotal / 1024 / 1024)}MB`)); } logNewlines(); let unusedDependencies = dependencies.filter((dep) => { const info = depInfoMap.get(dep); return (info.usedInFiles.length === 0 && info.requiredByPackages.size === 0); }); unusedDependencies = finalizeUnusedDependencies(unusedDependencies, depInfoMap, dependencies); unusedDependencies.sort(customSort); safeUnused.sort(customSort); const protectedUnused = []; const trulyUnused = []; for (const dep of unusedDependencies) { if (isProtectedDependency(dep) && !options.aggressive) { protectedUnused.push(dep); safeUnused.push(dep); } else { trulyUnused.push(dep); } } unusedDependencies = trulyUnused; if (unusedDependencies.length === 0 && safeUnused.length === 0) { console.log(chalk.green(MESSAGES.noUnusedDependencies)); } else if (unusedDependencies.length === 0 && safeUnused.length > 0) { console.log(chalk.bold(MESSAGES.unusedFound)); for (const dep of safeUnused) { const isSafeListed = options.safe?.includes(dep); console.log(chalk.blue(`- ${dep} [${isSafeListed ? "safe" : "protected"}]`)); } logNewlines(2); console.log(chalk.blue(MESSAGES.noChangesMade)); } else { console.log(chalk.bold(MESSAGES.unusedFound)); for (const dep of unusedDependencies) { console.log(chalk.yellow(`- ${dep}`)); } for (const dep of safeUnused) { const isSafeListed = options.safe?.includes(dep); console.log(chalk.blue(`- ${dep} [${isSafeListed ? "safe" : "protected"}]`)); } logNewlines(); if (options.verbose) { const table = new CliTable({ head: ["Dependency", "Direct Usage", "Required By"], wordWrap: true, colWidths: [25, 35, 20], style: { head: ["cyan"], border: ["grey"] }, }); const sortedDependencies = [...dependencies].sort(customSort); for (const dep of sortedDependencies) { const info = depInfoMap.get(dep); const fileUsage = info.usedInFiles.length > 0 ? info.usedInFiles .map((f) => path.relative(projectDirectory, f)) .join("\n") : chalk.gray("-"); const requiredBy = info.requiredByPackages.size > 0 ? [...info.requiredByPackages] .map((requestDep) => unusedDependencies.includes(requestDep) ? `${requestDep} ${chalk.blue("(unused)")}` : requestDep) .join(", ") : chalk.gray("-"); table.push([dep, fileUsage, requiredBy]); } console.log(table.toString()); logNewlines(); } if (options.measureImpact) { let totalInstallTime = 0; let totalDiskSpace = 0; const installResults = []; const measureSpinner = ora({ text: MESSAGES.measuringImpact, spinner: "dots", }).start(); activeSpinner = measureSpinner; const totalPackages = unusedDependencies.length; for (let index = 0; index < totalPackages; index++) { const dep = unusedDependencies[index]; try { const metrics = await measurePackageInstallation(dep); totalInstallTime += metrics.installTime; totalDiskSpace += metrics.diskSpace; installResults.push({ dep, time: metrics.installTime, space: metrics.diskSpace, errors: metrics.errors, }); const progress = `[${index + 1}/${totalPackages}] ${dep}`; measureSpinner.text = `${MESSAGES.measuringImpact} ${progress}`; } catch (error) { console.error(`Error measuring ${dep}:`, error); } } measureSpinner.stop(); console.log(`${MESSAGES.measuringImpact} [${totalPackages}/${totalPackages}] ${chalk.green("✔")}`); const parentInfo = await getParentPackageDownloads(packageJsonPath); logNewlines(); console.log(`${chalk.bold("Unused Dependency Impact Report:")} ${chalk.yellow(parentInfo?.name)} ${chalk.blue(`(${parentInfo?.homepage || parentInfo?.repository?.url || ""})`)}`); const impactData = {}; for (const result of installResults) { impactData[result.dep] = { installTime: `${result.time.toFixed(2)}s`, diskSpace: formatSize(result.space), }; } displayImpactTable(impactData, totalInstallTime, totalDiskSpace); const environmentalImpacts = []; for (const result of installResults) { const environmentImpact = calculateEnvironmentalImpact(result.space, result.time, parentInfo?.downloads || null); environmentalImpacts.push(environmentImpact); } const totalEnvironmentalImpact = calculateCumulativeEnvironmentalImpact(environmentalImpacts); logNewlines(); console.log(chalk.green.bold(MESSAGES.environmentalImpact)); displayEnvironmentalImpactTable(totalEnvironmentalImpact, "🌍 Total Environmental Impact"); if (unusedDependencies.length > 1) { logNewlines(); console.log(chalk.blue.bold("📦 Per-Package Environmental Impact:")); for (const [index, dep] of unusedDependencies.entries()) { const impact = environmentalImpacts[index]; console.log(chalk.blue(`\n${dep}:`)); displayEnvironmentalImpactTable(impact, `Package: ${dep}`); } } if (parentInfo) { const yearlyData = await getYearlyDownloads(parentInfo.name); const stats = calculateImpactStats(totalDiskSpace, totalInstallTime, parentInfo.downloads, yearlyData); const impactTable = new CliTable({ head: ["Period", "Downloads", "Data Transfer", "Install Time"], colWidths: [18, 20, 20, 20], wordWrap: true, style: { head: ["cyan"], border: ["grey"] }, }); if (stats.day) { impactTable.push([ "Day", `~${formatNumber(stats.day.downloads)}`, formatSize(stats.day.diskSpace), formatTime(stats.day.installTime), ]); } if (stats.monthly) { impactTable.push([ "Month", formatNumber(stats.monthly.downloads), formatSize(stats.monthly.diskSpace), formatTime(stats.monthly.installTime), ]); } if (yearlyData?.monthsFetched === 12 && stats.yearly && stats.yearly.downloads > 0) { impactTable.push([ "Last 12 months", formatNumber(stats.yearly.downloads), formatSize(stats.yearly.diskSpace), formatTime(stats.yearly.installTime), ]); } else if (yearlyData?.monthsFetched && yearlyData.monthsFetched > 1 && stats[`last_${yearlyData.monthsFetched}_months`] && (stats[`last_${yearlyData.monthsFetched}_months`]?.downloads ?? 0) > 0) { const label = `Last ${yearlyData.monthsFetched} months`; const periodStats = stats[`last_${yearlyData.monthsFetched}_months`]; impactTable.push([ label, formatNumber(periodStats?.downloads ?? 0), formatSize(periodStats?.diskSpace ?? 0), formatTime(periodStats?.installTime ?? 0), ]); } console.log(impactTable.toString()); if (totalEnvironmentalImpact) { logNewlines(); console.log(chalk.green.bold("💡 Environmental Impact Recommendations:")); const recommendations = generateEnvironmentalRecommendations(totalEnvironmentalImpact, unusedDependencies.length); for (const rec of recommendations) console.log(chalk.green(` ${rec}`)); logNewlines(); displayEnvironmentalHeroMessage(totalEnvironmentalImpact); } logNewlines(); console.log(`${chalk.yellow("Note:")} These results depend on your system's capabilities.\nTry a multi-architecture analysis at ${chalk.bold("https://github.com/chiefmikey/depsweep/analysis")}`); } else { logNewlines(); console.log(chalk.yellow("Insufficient download data to calculate impact")); } } if (!options.measureImpact) { console.log(chalk.blue("Run with the -m, --measure-impact flag to output a detailed impact analysis report")); } if (options.dryRun) { logNewlines(2); console.log(chalk.blue(MESSAGES.noChangesMade)); return; } logNewlines(2); const rl = readline.createInterface({ input, output }); activeReadline = rl; const packageManager = await detectPackageManager(projectDirectory); const answer = await rl.question(chalk.blue(MESSAGES.promptRemove)); if (answer.toLowerCase() === "y") { let uninstallCommand = ""; switch (packageManager) { case PACKAGE_MANAGERS.NPM: { uninstallCommand = `npm uninstall ${unusedDependencies.join(" ")}`; break; } case PACKAGE_MANAGERS.YARN: { uninstallCommand = `yarn remove ${unusedDependencies.join(" ")}`; break; } case PACKAGE_MANAGERS.PNPM: { uninstallCommand = `pnpm remove ${unusedDependencies.join(" ")}`; break; } default: { break; } } unusedDependencies = unusedDependencies.filter((dep) => isValidPackageName(dep)); if (unusedDependencies.length > 0) { try { safeExecSync(uninstallCommand.split(" "), { stdio: "inherit", cwd: projectDirectory, timeout: 300_000, }); } catch (error) { console.error(chalk.red("Failed to uninstall packages:"), error); process.exit(1); } } } else { console.log(chalk.blue(MESSAGES.noChangesMade)); } rl.close(); activeReadline = null; } performanceMonitor.endTimer("totalExecution"); if (options.verbose) { const totalTime = performanceMonitor.getMetrics().get("totalExecution")?.totalTime || 0; console.log(chalk.blue(`\nTotal execution time: ${totalTime.toFixed(2)}ms`)); } } catch (error) { cleanup(); console.error(chalk.red(MESSAGES.fatalError), error); process.exit(1); } } async function init() { try { const exitHandler = (signal) => { console.log(MESSAGES.signalCleanup.replace("{0}", signal)); cleanup(); process.exit(0); }; process.on("SIGINT", () => { exitHandler("SIGINT"); }); process.on("SIGTERM", () => { exitHandler("SIGTERM"); }); await main(); } catch (error) { cleanup(); console.error(chalk.red(MESSAGES.unexpected), error); process.exit(1); } } if (process.argv[1] && process.argv[1].endsWith("index.js")) { init().catch((error) => { console.error(chalk.red(MESSAGES.fatalError), error); process.exit(1); }); } function finalizeUnusedDependencies(initialUnusedDeps, depInfoMap, allDeps) { const unusedSet = new Set(initialUnusedDeps); let changed = true; while (changed) { changed = false; for (const dep of allDeps) { if (!unusedSet.has(dep)) { const info = depInfoMap.get(dep); if (info) { const allRequirersUnused = [...info.requiredByPackages].every((package_) => unusedSet.has(package_)); if (allRequirersUnused && info.usedInFiles.length === 0) { unusedSet.add(dep); changed = true; } } } } } return [...unusedSet]; }