Skip to content

Commit db7eb38

Browse files
authored
feat: display dependency paths when showing duplicates (#72)
* feat: display dependency paths when showing duplicates This shows the path to each version of a duplicate package. * wip: log some circles * fix: simplify paths logic * fix: use brs, unfortunately * fix: rework the styling * fix: traverse root only * fix: dedupe path computations * fix: show as a list * test: add duplicates tests
1 parent c0eaaa1 commit db7eb38

File tree

7 files changed

+434
-24
lines changed

7 files changed

+434
-24
lines changed

build/main.js

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24383,17 +24383,24 @@ var visitorKeys = [
2438324383
["peerDependency", "peerDependencies"],
2438424384
["optionalDependency", "optionalDependencies"]
2438524385
];
24386-
function traverse(node, visitor) {
24386+
function traverseInternal(node, visitor, path2) {
2438724387
for (const [visitorKey, nodeKey] of visitorKeys) {
2438824388
if (visitor[visitorKey]) {
24389+
const newPath = [...path2, node];
2438924390
for (const dep of node[nodeKey]) {
24390-
if (visitor[visitorKey](dep, node) !== false) {
24391-
traverse(dep, visitor);
24391+
if (path2.includes(dep)) {
24392+
continue;
24393+
}
24394+
if (visitor[visitorKey](dep, node, newPath) !== false) {
24395+
traverseInternal(dep, visitor, newPath);
2439224396
}
2439324397
}
2439424398
}
2439524399
}
2439624400
}
24401+
function traverse(node, visitor) {
24402+
return traverseInternal(node, visitor, []);
24403+
}
2439724404

2439824405
// node_modules/lockparse/lib/main.js
2439924406
var typeMap = {
@@ -24854,16 +24861,83 @@ function getLsCommand(lockfilePath, packageName) {
2485424861
}
2485524862
return void 0;
2485624863
}
24857-
function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath) {
24864+
function computeParentPaths(lockfile, duplicateDependencyNames, dependencyMap) {
24865+
const parentPaths = /* @__PURE__ */ new Map();
24866+
const visitorFn = (node, _parent, path2) => {
24867+
if (!duplicateDependencyNames.has(node.name) || !path2) {
24868+
return;
24869+
}
24870+
const versionSet = dependencyMap.get(node.name);
24871+
if (!versionSet) {
24872+
return;
24873+
}
24874+
const nodeKey = `${node.name}@${node.version}`;
24875+
if (parentPaths.has(nodeKey)) {
24876+
return;
24877+
}
24878+
const parentPath = path2.map((node2) => `${node2.name}@${node2.version}`);
24879+
parentPaths.set(nodeKey, parentPath);
24880+
};
24881+
const visitor = {
24882+
dependency: visitorFn,
24883+
devDependency: visitorFn,
24884+
optionalDependency: visitorFn
24885+
};
24886+
traverse(lockfile.root, visitor);
24887+
return parentPaths;
24888+
}
24889+
function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath, lockfile) {
2485824890
const duplicateRows = [];
24891+
const duplicateDependencyNames = /* @__PURE__ */ new Set();
2485924892
for (const [packageName, currentVersionSet] of dependencyMap) {
2486024893
if (currentVersionSet.size > threshold) {
24861-
const versions = Array.from(currentVersionSet).sort();
24862-
duplicateRows.push(
24863-
`| ${packageName} | ${currentVersionSet.size} versions | ${versions.join(", ")} |`
24864-
);
24894+
duplicateDependencyNames.add(packageName);
2486524895
}
2486624896
}
24897+
if (duplicateDependencyNames.size === 0) {
24898+
return;
24899+
}
24900+
const parentPaths = computeParentPaths(
24901+
lockfile,
24902+
duplicateDependencyNames,
24903+
dependencyMap
24904+
);
24905+
for (const name of duplicateDependencyNames) {
24906+
const versionSet = dependencyMap.get(name);
24907+
if (!versionSet) {
24908+
continue;
24909+
}
24910+
const versions = Array.from(versionSet).sort();
24911+
const detailsLines = [];
24912+
for (const version of versions) {
24913+
const pathKey = `${name}@${version}`;
24914+
const pathArray = parentPaths.get(pathKey);
24915+
if (pathArray && pathArray.length > 0) {
24916+
const maxDepth = 6;
24917+
const totalDepth = pathArray.length + 1;
24918+
let displayPath;
24919+
if (totalDepth > maxDepth) {
24920+
displayPath = [
24921+
...pathArray.slice(0, 2),
24922+
"...",
24923+
...pathArray.slice(-2)
24924+
];
24925+
} else {
24926+
displayPath = pathArray;
24927+
}
24928+
let nestedList = `<li>**${name}@${version}**</li>`;
24929+
for (let i = displayPath.length - 1; i >= 0; i--) {
24930+
nestedList = `<li>${displayPath[i]}<ul>${nestedList}</ul></li>`;
24931+
}
24932+
detailsLines.push(`<ul>${nestedList}</ul>`);
24933+
} else {
24934+
detailsLines.push(`**${name}@${version}**`);
24935+
}
24936+
}
24937+
const detailsContent = detailsLines.join("<br>");
24938+
const collapsibleSection = `<details><summary>${versionSet.size} version${versionSet.size > 1 ? "s" : ""}</summary><br>${detailsContent}<br></details>`;
24939+
duplicateRows.push(`| ${name} | ${collapsibleSection} |`);
24940+
}
2486724941
if (duplicateRows.length > 0) {
2486824942
const exampleCommand = getLsCommand(lockfilePath, "example-package");
2486924943
const helpMessage = exampleCommand ? `
@@ -24872,8 +24946,8 @@ function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath) {
2487224946
messages.push(
2487324947
`## \u26A0\uFE0F Duplicate Dependencies (threshold: ${threshold})
2487424948

24875-
| \u{1F4E6} Package | \u{1F522} Version Count | \u{1F4CB} Versions |
24876-
| --- | --- | --- |
24949+
| \u{1F4E6} Package | \u{1F4CB} Versions |
24950+
| --- | --- |
2487724951
${duplicateRows.join("\n")}${helpMessage}`
2487824952
);
2487924953
}
@@ -25210,7 +25284,13 @@ async function run() {
2521025284
currentDeps,
2521125285
baseDeps
2521225286
);
25213-
scanForDuplicates(messages, duplicateThreshold, currentDeps, lockfilePath);
25287+
scanForDuplicates(
25288+
messages,
25289+
duplicateThreshold,
25290+
currentDeps,
25291+
lockfilePath,
25292+
parsedCurrentLock
25293+
);
2521425294
await scanForDependencySize(
2521525295
messages,
2521625296
sizeThreshold,

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@types/node": "^24.9.0",
4545
"esbuild": "^0.25.11",
4646
"eslint": "^9.38.0",
47-
"lockparse": "^0.3.0",
47+
"lockparse": "^0.5.0",
4848
"module-replacements": "^2.9.0",
4949
"pkg-types": "^2.3.0",
5050
"prettier": "^3.6.2",

src/checks/duplicates.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {type ParsedLockFile, traverse, type VisitorFn} from 'lockparse';
2+
13
function getLsCommand(
24
lockfilePath: string,
35
packageName: string
@@ -17,22 +19,107 @@ function getLsCommand(
1719
return undefined;
1820
}
1921

22+
function computeParentPaths(
23+
lockfile: ParsedLockFile,
24+
duplicateDependencyNames: Set<string>,
25+
dependencyMap: Map<string, Set<string>>
26+
): Map<string, string[]> {
27+
const parentPaths = new Map<string, string[]>();
28+
29+
const visitorFn: VisitorFn = (node, _parent, path) => {
30+
if (!duplicateDependencyNames.has(node.name) || !path) {
31+
return;
32+
}
33+
const versionSet = dependencyMap.get(node.name);
34+
if (!versionSet) {
35+
return;
36+
}
37+
const nodeKey = `${node.name}@${node.version}`;
38+
if (parentPaths.has(nodeKey)) {
39+
return;
40+
}
41+
const parentPath = path.map((node) => `${node.name}@${node.version}`);
42+
parentPaths.set(nodeKey, parentPath);
43+
};
44+
const visitor = {
45+
dependency: visitorFn,
46+
devDependency: visitorFn,
47+
optionalDependency: visitorFn
48+
};
49+
50+
traverse(lockfile.root, visitor);
51+
52+
return parentPaths;
53+
}
54+
2055
export function scanForDuplicates(
2156
messages: string[],
2257
threshold: number,
2358
dependencyMap: Map<string, Set<string>>,
24-
lockfilePath: string
59+
lockfilePath: string,
60+
lockfile: ParsedLockFile
2561
): void {
2662
const duplicateRows: string[] = [];
63+
const duplicateDependencyNames = new Set<string>();
64+
2765
for (const [packageName, currentVersionSet] of dependencyMap) {
2866
if (currentVersionSet.size > threshold) {
29-
const versions = Array.from(currentVersionSet).sort();
30-
duplicateRows.push(
31-
`| ${packageName} | ${currentVersionSet.size} versions | ${versions.join(', ')} |`
32-
);
67+
duplicateDependencyNames.add(packageName);
3368
}
3469
}
3570

71+
if (duplicateDependencyNames.size === 0) {
72+
return;
73+
}
74+
75+
const parentPaths = computeParentPaths(
76+
lockfile,
77+
duplicateDependencyNames,
78+
dependencyMap
79+
);
80+
81+
for (const name of duplicateDependencyNames) {
82+
const versionSet = dependencyMap.get(name);
83+
if (!versionSet) {
84+
continue;
85+
}
86+
const versions = Array.from(versionSet).sort();
87+
88+
const detailsLines: string[] = [];
89+
for (const version of versions) {
90+
const pathKey = `${name}@${version}`;
91+
const pathArray = parentPaths.get(pathKey);
92+
if (pathArray && pathArray.length > 0) {
93+
const maxDepth = 6;
94+
const totalDepth = pathArray.length + 1;
95+
96+
let displayPath: string[];
97+
if (totalDepth > maxDepth) {
98+
displayPath = [
99+
...pathArray.slice(0, 2),
100+
'...',
101+
...pathArray.slice(-2)
102+
];
103+
} else {
104+
displayPath = pathArray;
105+
}
106+
107+
let nestedList = `<li>**${name}@${version}**</li>`;
108+
for (let i = displayPath.length - 1; i >= 0; i--) {
109+
nestedList = `<li>${displayPath[i]}<ul>${nestedList}</ul></li>`;
110+
}
111+
detailsLines.push(`<ul>${nestedList}</ul>`);
112+
} else {
113+
detailsLines.push(`**${name}@${version}**`);
114+
}
115+
}
116+
117+
const detailsContent = detailsLines.join('<br>');
118+
const collapsibleSection = `<details><summary>${versionSet.size} version${versionSet.size > 1 ? 's' : ''}</summary><br>${detailsContent}<br></details>`;
119+
120+
duplicateRows.push(`| ${name} | ${collapsibleSection} |`);
121+
}
122+
36123
if (duplicateRows.length > 0) {
37124
const exampleCommand = getLsCommand(lockfilePath, 'example-package');
38125
const helpMessage = exampleCommand
@@ -41,8 +128,8 @@ export function scanForDuplicates(
41128
messages.push(
42129
`## ⚠️ Duplicate Dependencies (threshold: ${threshold})
43130
44-
| 📦 Package | 🔢 Version Count | 📋 Versions |
45-
| --- | --- | --- |
131+
| 📦 Package | 📋 Versions |
132+
| --- | --- |
46133
${duplicateRows.join('\n')}${helpMessage}`
47134
);
48135
}

src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ async function run(): Promise<void> {
143143
currentDeps,
144144
baseDeps
145145
);
146-
scanForDuplicates(messages, duplicateThreshold, currentDeps, lockfilePath);
146+
scanForDuplicates(
147+
messages,
148+
duplicateThreshold,
149+
currentDeps,
150+
lockfilePath,
151+
parsedCurrentLock
152+
);
147153

148154
await scanForDependencySize(
149155
messages,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`scanForDuplicates > should report duplicates when threshold is exceeded 1`] = `
4+
[
5+
"## ⚠️ Duplicate Dependencies (threshold: 1)
6+
7+
| 📦 Package | 📋 Versions |
8+
| --- | --- |
9+
| package-a | <details><summary>2 versions</summary><br><ul><li>root-package@1.0.0<ul><li>**package-a@1.0.0**</li></ul></li></ul><br><ul><li>root-package@1.0.0<ul><li>package-b@2.0.0<ul><li>**package-a@1.1.0**</li></ul></li></ul></li></ul><br></details> |
10+
11+
💡 To find out what depends on a specific package, run: \`npm ls example-package\`",
12+
]
13+
`;
14+
15+
exports[`scanForDuplicates > should truncate long parent paths in the report 1`] = `
16+
[
17+
"## ⚠️ Duplicate Dependencies (threshold: 1)
18+
19+
| 📦 Package | 📋 Versions |
20+
| --- | --- |
21+
| package-a | <details><summary>2 versions</summary><br><ul><li>root-package@1.0.0<ul><li>**package-a@1.0.0**</li></ul></li></ul><br><ul><li>root-package@1.0.0<ul><li>package-0@1.0.0<ul><li>...<ul><li>package-18@1.0.0<ul><li>package-19@1.0.0<ul><li>**package-a@1.1.0**</li></ul></li></ul></li></ul></li></ul></li></ul></li></ul><br></details> |
22+
23+
💡 To find out what depends on a specific package, run: \`npm ls example-package\`",
24+
]
25+
`;

0 commit comments

Comments
 (0)