depopsy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import chalk from 'chalk';
2
+ import { isLowLevelPackage } from '../analyze/grouper.js';
3
+
4
+ // ── Helpers ───────────────────────────────────────────────────────────────────
5
+
6
+ function formatBytes(bytes) {
7
+ if (!bytes || bytes === 0) return null;
8
+ const kb = bytes / 1024;
9
+ if (kb < 1024) return `~${Math.round(kb)} KB`;
10
+ return `~${(kb / 1024).toFixed(2)} MB`;
11
+ }
12
+
13
+ function confidenceLabel(confidence) {
14
+ switch (confidence) {
15
+ case 'HIGH': return chalk.green('HIGH') + chalk.dim(' — safe to upgrade');
16
+ case 'MEDIUM': return chalk.yellow('MEDIUM') + chalk.dim(' — likely safe, verify first');
17
+ default: return chalk.red('LOW') + chalk.dim(' — manual review required');
18
+ }
19
+ }
20
+
21
+ function smartMessage(groupName, fixLikelihood, isLowLevel = false, chainCount = 0) {
22
+ // Strip version suffix (e.g. "@1.2.3") but preserve scoped names like "@mdx-js/loader"
23
+ let pkg = groupName || '';
24
+ if (pkg.startsWith('@')) {
25
+ // scoped: drop everything after the second '@' (version tag), if any
26
+ const versionAt = pkg.indexOf('@', 1);
27
+ if (versionAt > 0) pkg = pkg.substring(0, versionAt);
28
+ } else {
29
+ // unscoped: drop everything from the first '@'
30
+ pkg = pkg.replace(/@.*/, '');
31
+ }
32
+ pkg = pkg.trim();
33
+ if (!pkg) pkg = 'this dependency';
34
+
35
+ const chainImpact = chainCount > 0
36
+ ? `Upgrade ${pkg} → may resolve ${chainCount} duplicate chain${chainCount !== 1 ? 's' : ''}`
37
+ : `Upgrade ${pkg} to latest and re-run to measure impact`;
38
+
39
+ if (isLowLevel) {
40
+ return [
41
+ `Multiple versions are being pulled by different parts of your stack.`,
42
+ `Find which top-level dep depends on ${pkg} and upgrade that instead.`,
43
+ `Run: ${chalk.cyan(`npx depopsy trace ${pkg}`)} to see the full chain.`,
44
+ ];
45
+ }
46
+
47
+ if (fixLikelihood === 'HIGH') {
48
+ return [
49
+ `Versions are compatible — safe to consolidate automatically.`,
50
+ `${chainImpact}.`,
51
+ `Run: ${chalk.cyan('npx depopsy fix')} to apply.`,
52
+ ];
53
+ }
54
+ if (/^@babel/.test(pkg) || pkg === 'babel') {
55
+ return [
56
+ `Common in large projects with plugins and peer dependencies.`,
57
+ `Align all @babel/* packages to the same major version.`,
58
+ chainImpact + '.',
59
+ ];
60
+ }
61
+ if (pkg === 'eslint' || /^eslint-/.test(pkg)) {
62
+ return [
63
+ `Multiple versions are being pulled by different parts of your stack.`,
64
+ `Ensure all eslint-plugin-* packages target the same eslint major.`,
65
+ chainImpact + '.',
66
+ ];
67
+ }
68
+ if (pkg === 'webpack' || /webpack/.test(pkg)) {
69
+ return [
70
+ `Common in large projects with plugins and peer dependencies.`,
71
+ `Check if any loaders haven\'t been updated to webpack 5.`,
72
+ chainImpact + '.',
73
+ ];
74
+ }
75
+ if (pkg === 'jest' || pkg === 'vitest') {
76
+ return [
77
+ `Test tooling often bundles its own utility versions.`,
78
+ chainImpact + '.',
79
+ `Run: ${chalk.cyan('npx depopsy fix')} to apply safe fixes.`,
80
+ ];
81
+ }
82
+ if (pkg === 'next') {
83
+ return [
84
+ `Next.js bundles many internal dependencies with fixed versions.`,
85
+ `Upgrading Next.js usually resolves most of these conflicts.`,
86
+ chainImpact + '.',
87
+ ];
88
+ }
89
+ if (pkg === 'react' || pkg === 'react-dom') {
90
+ return [
91
+ `React 16/17/18 are not interchangeable — ensure everything uses the same major.`,
92
+ `Check all third-party component libraries for peer dep compatibility.`,
93
+ chainImpact + '.',
94
+ ];
95
+ }
96
+ return [
97
+ `Common in large projects with plugins and peer dependencies.`,
98
+ `Multiple versions are being pulled by different parts of your stack.`,
99
+ chainImpact + '.',
100
+ ];
101
+ }
102
+
103
+ // ── Text Report ───────────────────────────────────────────────────────────────
104
+
105
+ export function printTextReport(duplicates, rootCauses, options = {}) {
106
+ const isVerbose = options.verbose === true;
107
+ const isSimple = options.simple === true;
108
+ const topLimit = isSimple ? 3 : (options.top || 5);
109
+
110
+ const totalDuplicates = duplicates.length;
111
+ const safeCounts = duplicates.filter(d => d.safety === 'SAFE').length;
112
+ const riskyCounts = duplicates.filter(d => d.safety === 'RISKY').length;
113
+ const totalWaste = duplicates.reduce((acc, curr) => acc + curr.wastedBytes, 0);
114
+ const wasteStr = formatBytes(totalWaste);
115
+
116
+ const MISC = '⚠️ Misc / Low-level dependencies';
117
+ const HR = chalk.dim('─'.repeat(50));
118
+
119
+ // ── Header ─────────────────────────────────────────────────────────────────
120
+ console.log('');
121
+ console.log(chalk.bold.white('📦 Dependency Health Report'));
122
+ console.log(HR);
123
+
124
+ if (totalDuplicates === 0) {
125
+ console.log('');
126
+ console.log(chalk.green(' ✅ Your dependency tree is perfectly clean.'));
127
+ console.log(chalk.dim(' No duplicates found — great job!\n'));
128
+ console.log(chalk.dim('💡 Tip: Run this in CI to catch regressions early.\n'));
129
+ return;
130
+ }
131
+
132
+ // ── Problem Block ───────────────────────────────────────────────────────────
133
+ console.log('');
134
+ console.log(chalk.bold.red('🚨 Problem:'));
135
+ console.log(` You have ${chalk.bold.yellow(totalDuplicates)} duplicate dependencies`);
136
+ if (wasteStr) {
137
+ console.log(` ${chalk.dim('→')} ${chalk.magenta(wasteStr)} wasted on disk`);
138
+ }
139
+ console.log(` ${chalk.dim('→')} Slower installs, larger bundles, subtle runtime bugs`);
140
+
141
+ // ── Root Causes ─────────────────────────────────────────────────────────────
142
+ // Separate actionable from low-level
143
+ const allRoots = rootCauses.filter(rc => rc.name !== MISC);
144
+ const actionableRoots = allRoots.filter(rc => !isLowLevelPackage(rc.name));
145
+ const lowLevelRoots = allRoots.filter(rc => isLowLevelPackage(rc.name));
146
+
147
+ // Decide what to display
148
+ const hasActionable = actionableRoots.length > 0;
149
+ const displayRoots = hasActionable
150
+ ? actionableRoots.slice(0, isVerbose ? actionableRoots.length : topLimit)
151
+ : lowLevelRoots.slice(0, 3); // fallback: show top 3 with warning
152
+ const showingLowLevelFallback = !hasActionable && displayRoots.length > 0;
153
+
154
+ if (displayRoots.length > 0) {
155
+ console.log('');
156
+ if (showingLowLevelFallback) {
157
+ console.log(chalk.bold.yellow('⚠️ Low-level dependencies') + chalk.dim(' (indirect causes)'));
158
+ console.log(chalk.dim(' No high-level root causes found — showing closest available.'));
159
+ } else {
160
+ console.log(chalk.bold.white('🔥 Top Root Causes') + chalk.dim(' (you can act on)'));
161
+ console.log(chalk.dim(' These are the packages pulling in most duplicate dependencies.'));
162
+ }
163
+ console.log('');
164
+
165
+ displayRoots.forEach((group, i) => {
166
+ const pkgCount = group.affectedPackages.length;
167
+ // Guard: never display empty name
168
+ const displayName = (group.name && group.name.trim()) ? group.name : 'unknown package';
169
+ console.log(
170
+ ` ${chalk.bold(`${i + 1}.`)} ${chalk.cyan.bold(displayName)}` +
171
+ ` ${chalk.dim('→')} ${chalk.yellow(pkgCount)} duplicate package${pkgCount !== 1 ? 's' : ''}`
172
+ );
173
+ });
174
+
175
+ // Global impact line
176
+ const coveredCount = displayRoots.reduce((s, g) => s + g.count, 0);
177
+ if (totalDuplicates > 0 && coveredCount > 0) {
178
+ const pct = Math.round((coveredCount / (totalDuplicates * 2)) * 100);
179
+ const cappedPct = Math.min(pct, 99);
180
+ console.log('');
181
+ console.log(chalk.dim(` ➔ These top root causes account for ~${cappedPct}% of your duplication.`));
182
+ }
183
+
184
+ if (showingLowLevelFallback) {
185
+ console.log('');
186
+ console.log(chalk.dim(' ⚠️ These are low-level packages pulled in by higher-level libraries.'));
187
+ console.log(chalk.dim(' Use --verbose or --trace <pkg> to find their true introducer.'));
188
+ }
189
+ }
190
+
191
+ // ── Why it matters ──────────────────────────────────────────────────────────
192
+ if (!isSimple) {
193
+ console.log('');
194
+ console.log(chalk.bold.white('🎯 Why this matters'));
195
+ console.log(chalk.dim(' · Multiple versions of the same package increase bundle size'));
196
+ console.log(chalk.dim(' · Slows npm install / pnpm install / CI pipelines'));
197
+ console.log(chalk.dim(' · Can cause subtle runtime bugs when packages check instanceof'));
198
+ }
199
+
200
+ // ── Action blocks ───────────────────────────────────────────────────────────
201
+ if (!isSimple && displayRoots.length > 0) {
202
+ console.log('');
203
+ console.log(chalk.bold.white('🧠 What you should do'));
204
+
205
+ for (const group of displayRoots) {
206
+ const lowLevel = isLowLevelPackage(group.name);
207
+ // Guard: always use a defined, non-empty pkg name
208
+ let pkg = (group.name || '').trim();
209
+ if (!pkg || pkg === '') pkg = 'this dependency';
210
+ const confidence = group.confidence || group.fixLikelihood || 'LOW';
211
+ const msgs = smartMessage(group.name, group.fixLikelihood, lowLevel, group.count);
212
+ console.log('');
213
+ console.log(` ${chalk.cyan.bold('▶ ' + pkg)}`);
214
+ console.log(` ${chalk.dim('Confidence:')} ${confidenceLabel(confidence)}`);
215
+ const showPkgs = isVerbose
216
+ ? group.affectedPackages
217
+ : group.affectedPackages.slice(0, 4);
218
+ const hidden = group.affectedPackages.length - showPkgs.length;
219
+ console.log(` ${chalk.dim('Introduces:')} ${showPkgs.join(', ')}${hidden > 0 ? chalk.dim(` (+${hidden} more)`) : ''}`);
220
+ msgs.forEach(m => console.log(` ${chalk.dim('→')} ${m}`));
221
+ }
222
+ }
223
+
224
+ // ── Quick Fix ───────────────────────────────────────────────────────────────
225
+ if (safeCounts > 0) {
226
+ console.log('');
227
+ console.log(chalk.bold.white('⚡ Quick Fix'));
228
+ console.log(` Run: ${chalk.cyan.bold('npx depopsy fix')}`);
229
+ console.log(chalk.dim(` (applies ${safeCounts} SAFE fix${safeCounts !== 1 ? 'es' : ''} automatically — no breaking changes)`));
230
+ }
231
+
232
+ // ── Summary ─────────────────────────────────────────────────────────────────
233
+ console.log('');
234
+ console.log(chalk.bold.white('📊 Summary'));
235
+ console.log(HR);
236
+ console.log(` ${chalk.green('✔')} SAFE issues: ${chalk.green.bold(safeCounts)} ${chalk.dim('(auto-fixable)')}`);
237
+ console.log(` ${chalk.red('✖')} RISKY issues: ${chalk.red.bold(riskyCounts)} ${chalk.dim('(manual review)')}`);
238
+
239
+ if (lowLevelRoots.length > 0 && isVerbose) {
240
+ console.log('');
241
+ console.log(chalk.dim(` ℹ ${lowLevelRoots.length} low-level package group${lowLevelRoots.length !== 1 ? 's' : ''} hidden (utility libs)`));
242
+ console.log(chalk.dim(` Use --verbose to see them or --trace <pkg> to trace their origin.`));
243
+ }
244
+
245
+ const allHidden = actionableRoots.length - displayRoots.length;
246
+ if (hasActionable && allHidden > 0) {
247
+ console.log(chalk.dim(`\n + ${allHidden} more root cause group${allHidden !== 1 ? 's' : ''} — run with ${chalk.white('--verbose')} to see all`));
248
+ }
249
+
250
+ // ── Developer Tip ───────────────────────────────────────────────────────────
251
+ console.log('');
252
+ console.log(HR);
253
+ console.log(chalk.dim('💡 Tip: Even well-maintained projects have duplicates.'));
254
+ console.log(chalk.dim(' This tool explains *why* — not just what.'));
255
+ if (lowLevelRoots.length > 0 && !showingLowLevelFallback) {
256
+ console.log(chalk.dim(` Run: ${chalk.white('npx depopsy trace <pkg>')} to trace any package to its source.\n`));
257
+ } else {
258
+ console.log('');
259
+ }
260
+ }
261
+
262
+ // ── JSON Report ───────────────────────────────────────────────────────────────
263
+
264
+ export function printJsonReport(duplicates, rootCauses) {
265
+ const totalWaste = duplicates.reduce((acc, curr) => acc + curr.wastedBytes, 0);
266
+ const MISC = '⚠️ Misc / Low-level dependencies';
267
+
268
+ const jsonPayload = {
269
+ summary: {
270
+ total: duplicates.length,
271
+ safe: duplicates.filter(d => d.safety === 'SAFE').length,
272
+ risky: duplicates.filter(d => d.safety === 'RISKY').length,
273
+ wasteKB: (totalWaste / 1024).toFixed(2)
274
+ },
275
+ rootCauses: rootCauses
276
+ .filter(rc => rc.name !== MISC)
277
+ .map(rc => ({
278
+ name: rc.name,
279
+ affectedPackages: rc.affectedPackages,
280
+ count: rc.count,
281
+ confidence: rc.fixLikelihood,
282
+ lowLevel: isLowLevelPackage(rc.name)
283
+ })),
284
+ duplicates: duplicates.map(d => ({
285
+ name: d.name,
286
+ versions: d.versions,
287
+ safety: d.safety,
288
+ confidence: d.confidence,
289
+ recommended: d.suggestedVersion,
290
+ roots: [...new Set(d.details.flatMap(det => det.roots || []))].filter(Boolean)
291
+ })),
292
+ suggestions: duplicates
293
+ .filter(d => d.safety === 'SAFE' && d.suggestedVersion)
294
+ .map(d => ({ name: d.name, targetVersion: d.suggestedVersion }))
295
+ };
296
+
297
+ console.log(JSON.stringify(jsonPayload, null, 2));
298
+ }
299
+
300
+ // ── CI Report ─────────────────────────────────────────────────────────────────
301
+
302
+ export function printCiReport(duplicates) {
303
+ console.log(JSON.stringify({
304
+ status: duplicates.length > 0 ? 'fail' : 'success',
305
+ total: duplicates.length,
306
+ safe: duplicates.filter(d => d.safety === 'SAFE').length,
307
+ risky: duplicates.filter(d => d.safety === 'RISKY').length,
308
+ }, null, 2));
309
+ }
@@ -0,0 +1,50 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+
5
+ export async function detectWorkspaces(projectDir, pkg) {
6
+ const workspaces = {
7
+ packages: new Set(),
8
+ isMonorepo: false,
9
+ };
10
+
11
+ // 1. Check package.json workspaces
12
+ if (pkg && pkg.workspaces) {
13
+ workspaces.isMonorepo = true;
14
+ // workspaces can be an array or an object with 'packages' array
15
+ const wp = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages;
16
+ if (Array.isArray(wp)) {
17
+ // Just flag that it's a monorepo. We could use fast-glob to find exact package names,
18
+ // but for dependency bloat, external dependencies are the pain.
19
+ // Easiest heuristic: we'll mark this.
20
+ }
21
+ }
22
+
23
+ // 2. Check pnpm-workspace.yaml
24
+ try {
25
+ const pnpmWsPath = path.join(projectDir, 'pnpm-workspace.yaml');
26
+ const pnpmWsObj = yaml.load(await fs.readFile(pnpmWsPath, 'utf8'));
27
+ if (pnpmWsObj && pnpmWsObj.packages) {
28
+ workspaces.isMonorepo = true;
29
+ }
30
+ } catch (e) {
31
+ // Ignore if not present
32
+ }
33
+
34
+ // To truly find workspace package names requires globbing.
35
+ // Instead, we will rely on lockfiles which natively mark links or workspace protocols:
36
+ // pnpm: version starts with 'link:'
37
+ // yarn: version starts with 'workspace:'
38
+ // npm: has a 'link' boolean
39
+
40
+ return workspaces;
41
+ }
42
+
43
+ export function isLocalVersion(versionInfo) {
44
+ // Checks if a lockfile version string is a local reference
45
+ if (!versionInfo) return false;
46
+ return versionInfo.startsWith('link:') ||
47
+ versionInfo.startsWith('workspace:') ||
48
+ versionInfo.startsWith('file:') ||
49
+ versionInfo === '0.0.0-use.local';
50
+ }