depsweep
Version:
🌱 Automated intelligent dependency cleanup with environmental impact reporting
500 lines (499 loc) • 22.8 kB
JavaScript
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];
}