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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dep-optimizer contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,309 @@
1
+ <div align="center">
2
+
3
+ # Depopsy ๐Ÿš€
4
+
5
+ ### The npm doctor for dependency chaos.
6
+
7
+ **Understand *why* your dependencies are bloated โ€” and fix them safely.**
8
+
9
+ [![npm version](https://img.shields.io/npm/v/depopsy?color=crimson&label=npm&logo=npm)](https://www.npmjs.com/package/depopsy)
10
+ [![npm downloads](https://img.shields.io/npm/dm/depopsy?color=blue&logo=npm)](https://www.npmjs.com/package/depopsy)
11
+ [![Node.js >=18](https://img.shields.io/badge/node-%3E%3D18-brightgreen?logo=node.js)](https://nodejs.org)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](./LICENSE)
13
+ ![Supports npm ยท yarn ยท pnpm](https://img.shields.io/badge/supports-npm%20%C2%B7%20yarn%20%C2%B7%20pnpm-8A2BE2)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## The Problem
20
+
21
+ Modern JavaScript projects silently accumulate **multiple versions of the same package**. After a few months of adding dependencies, you might have `lodash@4.17.19` *and* `lodash@4.17.21` installed simultaneously โ€” pulled in by different tools like `eslint`, `jest`, or `next`.
22
+
23
+ This leads to:
24
+
25
+ - ๐Ÿข **Slower installs** โ€” `npm install` / CI pipelines download and extract duplicate packages
26
+ - ๐Ÿ“ฆ **Larger bundles** โ€” your end users download the bloat too
27
+ - ๐Ÿ› **Subtle runtime bugs** โ€” packages that check `instanceof` silently break when two versions exist
28
+
29
+ `npm dedupe` tells you *what* is duplicated. `depopsy` tells you ***why*** โ€” and fixes it safely.
30
+
31
+ ---
32
+
33
+ ## Quick Start
34
+
35
+ Zero install required. Run it at the root of **any** npm, yarn, or pnpm project:
36
+
37
+ ```bash
38
+ npx depopsy
39
+ ```
40
+
41
+ For a quick 3-line summary:
42
+
43
+ ```bash
44
+ npx depopsy --simple
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Example Output
50
+
51
+ ```
52
+ Analyzing large dependency graph...
53
+ Detected Package Manager: npm
54
+
55
+ ๐Ÿ“ฆ Dependency Health Report
56
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
57
+
58
+ ๐Ÿšจ Problem:
59
+ You have 19 duplicate dependencies
60
+ โ†’ ~1.89 MB wasted on disk
61
+ โ†’ Slower installs, larger bundles, subtle runtime bugs
62
+
63
+ ๐Ÿ”ฅ Top Root Causes (you can act on)
64
+ These are the packages pulling in most duplicate dependencies.
65
+
66
+ 1. jest โ†’ 19 duplicate packages
67
+
68
+ โž” These top root causes account for ~89% of your duplication.
69
+
70
+ ๐ŸŽฏ Why this matters
71
+ ยท Multiple versions of the same package increase bundle size
72
+ ยท Slows npm install / pnpm install / CI pipelines
73
+ ยท Can cause subtle runtime bugs when packages check instanceof
74
+
75
+ ๐Ÿง  What you should do
76
+
77
+ โ–ถ jest
78
+ Confidence: LOW โ€” manual review required
79
+ Introduces: ansi-styles, chalk, ansi-regex, semver (+15 more)
80
+ โ†’ Test tooling often bundles its own utility versions.
81
+ โ†’ Upgrade jest to latest โ€” may resolve 19 duplicate chains.
82
+
83
+ โšก Quick Fix
84
+ Run: npx depopsy fix
85
+ (applies 0 SAFE fixes automatically โ€” no breaking changes)
86
+
87
+ ๐Ÿ“Š Summary
88
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
89
+ โœ” SAFE issues: 0 (auto-fixable)
90
+ โœ– RISKY issues: 19 (manual review)
91
+
92
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
93
+ ๐Ÿ’ก Tip: Even well-maintained projects have duplicates.
94
+ This tool explains *why* โ€” not just what.
95
+ ```
96
+
97
+ ---
98
+
99
+ ## CLI Reference
100
+
101
+ | Command | Description |
102
+ |---|---|
103
+ | `npx depopsy` | Full dependency health report |
104
+ | `npx depopsy --simple` | Top 3 root causes only |
105
+ | `npx depopsy --verbose` | Full breakdown of every group |
106
+ | `npx depopsy --top <n>` | Limit root cause output to top N groups |
107
+ | `npx depopsy --json` | JSON output for CI/CD pipelines |
108
+ | `npx depopsy --ci` | Minimal JSON with correct exit codes |
109
+ | `npx depopsy fix` | Dry-run: preview safe deduplication fixes |
110
+ | `npx depopsy fix --yes` | Apply fixes directly to `package.json` |
111
+ | `npx depopsy trace <pkg>` | Trace which top-level dep introduces `<pkg>` |
112
+
113
+ ### Exit Codes
114
+
115
+ | Code | Meaning |
116
+ |---|---|
117
+ | `0` | Success โ€” no duplicates found |
118
+ | `1` | Duplicates found |
119
+ | `2` | Fatal error (no lockfile, parse failure, etc.) |
120
+
121
+ ---
122
+
123
+ ## How It Works
124
+
125
+ ```
126
+ 1. Parses your lockfile โ†’ package-lock.json, yarn.lock, or pnpm-lock.yaml
127
+ 2. Builds a dependency graph โ†’ maps every package to its introducers
128
+ 3. Detects duplicates โ†’ packages installed at multiple versions
129
+ 4. Identifies root causes โ†’ which top-level dep (eslint, jest, next) is responsible
130
+ 5. Suggests safe fixes โ†’ only semver-compatible consolidations
131
+ ```
132
+
133
+ ### Root Cause Attribution
134
+
135
+ The attribution engine uses a 3-layer priority system to find the most actionable signal:
136
+
137
+ 1. **`roots[]`** โ€” Pre-computed top-level introducers via graph traversal (most accurate)
138
+ 2. **`parents[]`** โ€” Immediate dependents (closer signal than full ancestor chain)
139
+ 3. **`ancestors[]`** โ€” Full flattened chain (last resort, only when no better signal exists)
140
+
141
+ Low-level utility packages (`chalk`, `semver`, `minimatch`, `glob`, etc.) are automatically suppressed from root cause output โ€” they are always *indirect* symptoms, never *causes*.
142
+
143
+ ---
144
+
145
+ ## Features
146
+
147
+ | Feature | Detail |
148
+ |---|---|
149
+ | โœ… Multi-lockfile | npm (`package-lock.json`), yarn (`yarn.lock`), pnpm (`pnpm-lock.yaml`) |
150
+ | โœ… Root cause analysis | Identifies *which* top-level dep pulls in each duplicate |
151
+ | โœ… Safe fixes only | Backs up `package.json` before writing; never touches RISKY diffs |
152
+ | โœ… CI/CD ready | `--json` and `--ci` flags with correct exit codes |
153
+ | โœ… Zero config | Works instantly in any project root โ€” no setup required |
154
+ | โœ… Monorepo aware | Skips internal workspace packages (`link:`, `workspace:`, `file:`) |
155
+ | โœ… Disk waste measurement | Actual on-disk byte measurement of duplicate package directories |
156
+ | โœ… Package manager detection | Auto-detects npm / yarn / pnpm from lockfile presence |
157
+ | โœ… Yarn v1 + v2+ | Handles both classic and modern Yarn lockfile formats |
158
+ | โœ… pnpm v5โ€“v9 | Handles all pnpm lockfile versions including v9 snapshots |
159
+
160
+ ---
161
+
162
+ ## The `fix` Command
163
+
164
+ `depopsy fix` only touches **SAFE** duplicates โ€” packages where all versions share the same major version (semver-compatible).
165
+
166
+ ```bash
167
+ # Preview what would change (no files modified)
168
+ npx depopsy fix
169
+
170
+ # Apply changes to package.json
171
+ npx depopsy fix --yes
172
+ ```
173
+
174
+ After running `fix --yes`, you **must** reinstall to apply the overrides to your lockfile:
175
+
176
+ ```bash
177
+ npm install # or yarn install / pnpm install
178
+ ```
179
+
180
+ ### What it writes
181
+
182
+ | Package manager | What gets written |
183
+ |---|---|
184
+ | npm | `"overrides"` in `package.json` |
185
+ | yarn | `"resolutions"` in `package.json` |
186
+ | pnpm | `"pnpm.overrides"` in `package.json` |
187
+
188
+ A backup of your original `package.json` is saved to `.depopsy-backup/` before any write.
189
+
190
+ ---
191
+
192
+ ## The `trace` Command
193
+
194
+ Trace exactly which top-level dependencies are responsible for pulling in a given package:
195
+
196
+ ```bash
197
+ npx depopsy trace semver
198
+ ```
199
+
200
+ ```
201
+ Tracing "semver" through dependency graph...
202
+
203
+ ๐Ÿ” Trace: semver
204
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
205
+
206
+ Versions found: 5.7.2, 7.5.4, 7.6.0
207
+ Safety: RISKY
208
+
209
+ semver is introduced by:
210
+ โ–ถ jest
211
+ โ–ถ eslint
212
+ โ–ถ @babel/core
213
+
214
+ Per-version breakdown:
215
+ 5.7.2 โ€” required by: node-semver, validate-npm-package-version
216
+ 7.5.4 โ€” required by: jest-runner, jest-resolve
217
+ 7.6.0 โ€” required by: eslint, @babel/core
218
+
219
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
220
+ โš  No auto-fix available โ€” versions span multiple majors.
221
+ ```
222
+
223
+ ---
224
+
225
+ ## CI/CD Integration
226
+
227
+ ### GitHub Actions
228
+
229
+ ```yaml
230
+ - name: Check for duplicate dependencies
231
+ run: npx depopsy --ci
232
+ # Exits 1 if duplicates found, 0 if clean
233
+ ```
234
+
235
+ ### JSON output for custom scripting
236
+
237
+ ```bash
238
+ npx depopsy --json > dep-report.json
239
+ ```
240
+
241
+ ```json
242
+ {
243
+ "summary": {
244
+ "total": 19,
245
+ "safe": 4,
246
+ "risky": 15,
247
+ "wasteKB": "1934.22"
248
+ },
249
+ "rootCauses": [...],
250
+ "duplicates": [...],
251
+ "suggestions": [...]
252
+ }
253
+ ```
254
+
255
+ ---
256
+
257
+ ## FAQ
258
+
259
+ **Q: How is this different from `npm dedupe`?**
260
+
261
+ `npm dedupe` reorganizes `node_modules` at install time but doesn't prevent the problem from recurring. `depopsy` analyzes your *lockfile* to explain the root cause and writes `overrides`/`resolutions` to your `package.json` โ€” so the deduplication survives a fresh `npm install`.
262
+
263
+ **Q: Is it safe to run on production projects?**
264
+
265
+ Yes. The `analyze` and `trace` commands are fully **read-only**. The `fix` command defaults to a dry-run โ€” you must explicitly pass `--yes` to make changes, and it always creates a backup first.
266
+
267
+ **Q: Does it work with workspaces / monorepos?**
268
+
269
+ Yes. It automatically detects workspace packages and excludes them from root cause attribution (they use `link:`, `workspace:`, or `file:` version strings in lockfiles).
270
+
271
+ **Q: Why does it show "RISKY" for some duplicates?**
272
+
273
+ RISKY means the duplicate versions span **multiple major versions** (e.g., `react@17` and `react@18`). These cannot be safely auto-aligned because major version bumps can include breaking changes. Use `depopsy trace <pkg>` to understand which dependency is causing it.
274
+
275
+ **Q: Why don't I see `semver` or `chalk` as root causes?**
276
+
277
+ These are *leaf* utility packages โ€” they are always pulled in transitively, never directly causing bloat. `depopsy` suppresses them from root cause output to keep results actionable. Use `--verbose` or `trace` to inspect them.
278
+
279
+ ---
280
+
281
+ ## Requirements
282
+
283
+ - **Node.js** `>= 18.0.0`
284
+ - A project with at least one lockfile: `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`
285
+
286
+ ---
287
+
288
+ ## Contributing
289
+
290
+ ```bash
291
+ git clone https://github.com/depopsy/depopsy
292
+ cd depopsy
293
+ npm install
294
+ npm test
295
+ ```
296
+
297
+ Please open an issue before submitting a pull request for significant changes.
298
+
299
+ ---
300
+
301
+ ## License
302
+
303
+ [MIT](./LICENSE) ยฉ 2026 depopsy contributors
304
+
305
+ ---
306
+
307
+ <div align="center">
308
+ Built with โค๏ธ for the open-source JavaScript ecosystem.
309
+ </div>
package/bin/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { setupCommands } from '../src/cli/index.js';
5
+ import { fileURLToPath } from 'url';
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ // Resolve version from this package's own package.json.
12
+ // Works correctly whether invoked via: node bin/cli.js, npm link, npm install -g, or npx.
13
+ let version = '1.0.0';
14
+ try {
15
+ const pkgPath = path.join(__dirname, '..', 'package.json');
16
+ version = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
17
+ } catch {
18
+ // Silently fall back โ€” the CLI is still fully functional without a version string.
19
+ }
20
+
21
+ program
22
+ .name('depopsy')
23
+ .description('NPM doctor for dependency bloat. Detects and resolves duplicated dependencies in your lockfile.')
24
+ .version(version, '-v, --version', 'Output the current version')
25
+ .addHelpText('after', `
26
+ Examples:
27
+ $ depopsy Full dependency health report
28
+ $ depopsy --simple Top 3 root causes only
29
+ $ depopsy --verbose Full breakdown of every group
30
+ $ depopsy --json JSON output for CI/CD pipelines
31
+ $ depopsy --ci Minimal JSON + exit codes for CI
32
+ $ depopsy fix Dry-run safe deduplication fixes
33
+ $ depopsy fix --yes Apply fixes to package.json
34
+ $ depopsy trace <pkg> Trace a package to its root cause
35
+ `);
36
+
37
+ setupCommands(program);
38
+
39
+ async function runCLI() {
40
+ await program.parseAsync(process.argv);
41
+ }
42
+
43
+ try {
44
+ await runCLI();
45
+ } catch (err) {
46
+ console.error("โŒ Error:", err.message);
47
+ process.exit(1);
48
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "depopsy",
3
+ "version": "1.0.0",
4
+ "description": "Understand why your dependencies are bloated โ€” detect and fix duplicated packages in your lockfile.",
5
+ "bin": {
6
+ "depopsy": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "src/",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "keywords": [
19
+ "dependencies",
20
+ "duplicates",
21
+ "npm",
22
+ "pnpm",
23
+ "yarn",
24
+ "optimization",
25
+ "cli",
26
+ "lockfile",
27
+ "deduplication",
28
+ "bloat",
29
+ "bundle",
30
+ "monorepo",
31
+ "root-cause",
32
+ "package-manager"
33
+ ],
34
+ "author": "depopsy contributors",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/depopsy/depopsy"
39
+ },
40
+ "homepage": "https://github.com/depopsy/depopsy#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/depopsy/depopsy/issues"
43
+ },
44
+ "dependencies": {
45
+ "@yarnpkg/lockfile": "^1.1.0",
46
+ "chalk": "^5.6.2",
47
+ "commander": "^14.0.3",
48
+ "js-yaml": "^4.1.1",
49
+ "semver": "^7.7.4"
50
+ }
51
+ }
@@ -0,0 +1,144 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import semver from 'semver';
4
+
5
+ /**
6
+ * Super fast directory size calculator.
7
+ * Silently fails and returns 0 if directory doesn't exist or there's a permission error.
8
+ */
9
+ async function getDirectorySize(dirPath) {
10
+ let size = 0;
11
+ try {
12
+ const files = await fs.readdir(dirPath, { withFileTypes: true });
13
+ for (const file of files) {
14
+ const fullPath = path.join(dirPath, file.name);
15
+ if (file.isDirectory()) {
16
+ size += await getDirectorySize(fullPath);
17
+ } else {
18
+ const stats = await fs.stat(fullPath);
19
+ size += stats.size;
20
+ }
21
+ }
22
+ } catch (error) {
23
+ // Ignore errors (e.g., ENOENT if not installed)
24
+ return 0;
25
+ }
26
+ return size;
27
+ }
28
+
29
+ /**
30
+ * Analyzes the parsed packages map and returns a list of duplicated packages.
31
+ * @param {Map<string, { instances: string[], versions: Set<string> }>} packagesMap
32
+ * @param {string} projectDir
33
+ * @returns {Promise<Array>} List of duplicates, sorted by severity/instances
34
+ */
35
+ export async function detectDuplicates(packagesMap, projectDir) {
36
+ const duplicates = [];
37
+
38
+ for (const [name, data] of packagesMap.entries()) {
39
+ if (data.versions.size > 1) {
40
+ let totalWastedBytes = 0;
41
+ let approxSize = false;
42
+
43
+ // Group instances by version
44
+ const instancesByVersion = new Map();
45
+ for (const instance of data.instances) {
46
+ if (!instancesByVersion.has(instance.version)) {
47
+ instancesByVersion.set(instance.version, []);
48
+ }
49
+ instancesByVersion.get(instance.version).push(instance);
50
+ }
51
+
52
+ // Evaluate safety using semver
53
+ const validVersions = Array.from(data.versions).filter(v => semver.valid(semver.coerce(v)));
54
+ const coercedVersions = validVersions.map(v => semver.coerce(v));
55
+ const majors = new Set(coercedVersions.map(v => v.major));
56
+
57
+ const safety = majors.size === 1 ? 'SAFE' : 'RISKY';
58
+ let suggestedVersion = null;
59
+
60
+ // Calculate instances counts
61
+ const counts = Array.from(instancesByVersion.entries()).map(([v, instances]) => ({
62
+ version: v,
63
+ count: instances.length
64
+ })).sort((a, b) => b.count - a.count);
65
+
66
+ const mostFrequent = counts[0];
67
+
68
+ if (safety === 'SAFE') {
69
+ const sortedOriginals = [...validVersions].sort((a, b) => {
70
+ return semver.rcompare(semver.coerce(a), semver.coerce(b));
71
+ });
72
+
73
+ // Popularity heuristic: if one version is overwhelmingly popular (>= 80% usage), prefer it
74
+ if (mostFrequent && mostFrequent.count >= data.instances.length * 0.8) {
75
+ suggestedVersion = mostFrequent.version;
76
+ } else {
77
+ // Otherwise, highest version
78
+ suggestedVersion = sortedOriginals[0];
79
+ }
80
+ } else {
81
+ // RISKY: optionally suggest highest version as a direction, but we keep it null to avoid auto-fix
82
+ suggestedVersion = null;
83
+ }
84
+
85
+ const duplicateVersionsInfo = [];
86
+
87
+ // Calculate sizes for non-canonical versions (wasted sizes)
88
+ for (const [version, instances] of instancesByVersion.entries()) {
89
+ let versionBytes = 0;
90
+ const allParents = new Set();
91
+ const allAncestors = new Set();
92
+ const allRoots = new Set();
93
+
94
+ // Calculate size on disk if available (only for this version's instances)
95
+ for (const inst of instances) {
96
+ const absolutePath = path.join(projectDir, inst.path);
97
+ const size = await getDirectorySize(absolutePath);
98
+ versionBytes += size;
99
+
100
+ if (inst.parents) inst.parents.forEach(p => allParents.add(p));
101
+ if (inst.allParents) inst.allParents.forEach(a => allAncestors.add(a));
102
+ if (inst.roots) inst.roots.forEach(r => allRoots.add(r));
103
+ }
104
+
105
+ duplicateVersionsInfo.push({
106
+ version,
107
+ count: instances.length,
108
+ instances,
109
+ parents: Array.from(allParents),
110
+ ancestors: Array.from(allAncestors),
111
+ roots: Array.from(allRoots),
112
+ sizeBytes: versionBytes
113
+ });
114
+
115
+ // Any version that isn't the primary selected version is considered "waste" (optimistic assumption)
116
+ if (safety === 'SAFE' && version !== suggestedVersion) {
117
+ totalWastedBytes += versionBytes;
118
+ } else if (safety === 'RISKY' && version !== mostFrequent.version) {
119
+ totalWastedBytes += versionBytes;
120
+ }
121
+ }
122
+
123
+ duplicates.push({
124
+ name,
125
+ versions: Array.from(data.versions),
126
+ totalInstances: data.instances.length,
127
+ wastedBytes: totalWastedBytes,
128
+ suggestedVersion,
129
+ safety,
130
+ details: duplicateVersionsInfo.sort((a, b) => b.count - a.count)
131
+ });
132
+ }
133
+ }
134
+
135
+ // Sort by wasted bytes (if available) then by total instances
136
+ duplicates.sort((a, b) => {
137
+ if (b.wastedBytes !== a.wastedBytes) {
138
+ return b.wastedBytes - a.wastedBytes;
139
+ }
140
+ return b.totalInstances - a.totalInstances;
141
+ });
142
+
143
+ return duplicates;
144
+ }