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 +21 -0
- package/README.md +309 -0
- package/bin/cli.js +48 -0
- package/package.json +51 -0
- package/src/analyze/detector.js +144 -0
- package/src/analyze/grouper.js +298 -0
- package/src/analyze/scorer.js +40 -0
- package/src/cli/analyze-command.js +57 -0
- package/src/cli/fix-command.js +37 -0
- package/src/cli/index.js +27 -0
- package/src/cli/trace-command.js +104 -0
- package/src/fix/fixer.js +90 -0
- package/src/graph/builder.js +30 -0
- package/src/parser/graph.js +64 -0
- package/src/parser/index.js +47 -0
- package/src/parser/npm-parser.js +143 -0
- package/src/parser/pnpm-parser.js +257 -0
- package/src/parser/yarn-parser.js +93 -0
- package/src/report/formatter.js +309 -0
- package/src/utils/workspace.js +50 -0
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
|
+
[](https://www.npmjs.com/package/depopsy)
|
|
10
|
+
[](https://www.npmjs.com/package/depopsy)
|
|
11
|
+
[](https://nodejs.org)
|
|
12
|
+
[](./LICENSE)
|
|
13
|
+

|
|
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
|
+
}
|