diff --git a/build/main.js b/build/main.js
index e54bf6e..f2d4c32 100644
--- a/build/main.js
+++ b/build/main.js
@@ -24383,17 +24383,24 @@ var visitorKeys = [
["peerDependency", "peerDependencies"],
["optionalDependency", "optionalDependencies"]
];
-function traverse(node, visitor) {
+function traverseInternal(node, visitor, path2) {
for (const [visitorKey, nodeKey] of visitorKeys) {
if (visitor[visitorKey]) {
+ const newPath = [...path2, node];
for (const dep of node[nodeKey]) {
- if (visitor[visitorKey](dep, node) !== false) {
- traverse(dep, visitor);
+ if (path2.includes(dep)) {
+ continue;
+ }
+ if (visitor[visitorKey](dep, node, newPath) !== false) {
+ traverseInternal(dep, visitor, newPath);
}
}
}
}
}
+function traverse(node, visitor) {
+ return traverseInternal(node, visitor, []);
+}
// node_modules/lockparse/lib/main.js
var typeMap = {
@@ -24854,16 +24861,83 @@ function getLsCommand(lockfilePath, packageName) {
}
return void 0;
}
-function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath) {
+function computeParentPaths(lockfile, duplicateDependencyNames, dependencyMap) {
+ const parentPaths = /* @__PURE__ */ new Map();
+ const visitorFn = (node, _parent, path2) => {
+ if (!duplicateDependencyNames.has(node.name) || !path2) {
+ return;
+ }
+ const versionSet = dependencyMap.get(node.name);
+ if (!versionSet) {
+ return;
+ }
+ const nodeKey = `${node.name}@${node.version}`;
+ if (parentPaths.has(nodeKey)) {
+ return;
+ }
+ const parentPath = path2.map((node2) => `${node2.name}@${node2.version}`);
+ parentPaths.set(nodeKey, parentPath);
+ };
+ const visitor = {
+ dependency: visitorFn,
+ devDependency: visitorFn,
+ optionalDependency: visitorFn
+ };
+ traverse(lockfile.root, visitor);
+ return parentPaths;
+}
+function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath, lockfile) {
const duplicateRows = [];
+ const duplicateDependencyNames = /* @__PURE__ */ new Set();
for (const [packageName, currentVersionSet] of dependencyMap) {
if (currentVersionSet.size > threshold) {
- const versions = Array.from(currentVersionSet).sort();
- duplicateRows.push(
- `| ${packageName} | ${currentVersionSet.size} versions | ${versions.join(", ")} |`
- );
+ duplicateDependencyNames.add(packageName);
}
}
+ if (duplicateDependencyNames.size === 0) {
+ return;
+ }
+ const parentPaths = computeParentPaths(
+ lockfile,
+ duplicateDependencyNames,
+ dependencyMap
+ );
+ for (const name of duplicateDependencyNames) {
+ const versionSet = dependencyMap.get(name);
+ if (!versionSet) {
+ continue;
+ }
+ const versions = Array.from(versionSet).sort();
+ const detailsLines = [];
+ for (const version of versions) {
+ const pathKey = `${name}@${version}`;
+ const pathArray = parentPaths.get(pathKey);
+ if (pathArray && pathArray.length > 0) {
+ const maxDepth = 6;
+ const totalDepth = pathArray.length + 1;
+ let displayPath;
+ if (totalDepth > maxDepth) {
+ displayPath = [
+ ...pathArray.slice(0, 2),
+ "...",
+ ...pathArray.slice(-2)
+ ];
+ } else {
+ displayPath = pathArray;
+ }
+ let nestedList = `
**${name}@${version}**`;
+ for (let i = displayPath.length - 1; i >= 0; i--) {
+ nestedList = `${displayPath[i]}`;
+ }
+ detailsLines.push(``);
+ } else {
+ detailsLines.push(`**${name}@${version}**`);
+ }
+ }
+ const detailsContent = detailsLines.join("
");
+ const collapsibleSection = `${versionSet.size} version${versionSet.size > 1 ? "s" : ""}
${detailsContent}
`;
+ duplicateRows.push(`| ${name} | ${collapsibleSection} |`);
+ }
if (duplicateRows.length > 0) {
const exampleCommand = getLsCommand(lockfilePath, "example-package");
const helpMessage = exampleCommand ? `
@@ -24872,8 +24946,8 @@ function scanForDuplicates(messages, threshold, dependencyMap, lockfilePath) {
messages.push(
`## \u26A0\uFE0F Duplicate Dependencies (threshold: ${threshold})
-| \u{1F4E6} Package | \u{1F522} Version Count | \u{1F4CB} Versions |
-| --- | --- | --- |
+| \u{1F4E6} Package | \u{1F4CB} Versions |
+| --- | --- |
${duplicateRows.join("\n")}${helpMessage}`
);
}
@@ -25210,7 +25284,13 @@ async function run() {
currentDeps,
baseDeps
);
- scanForDuplicates(messages, duplicateThreshold, currentDeps, lockfilePath);
+ scanForDuplicates(
+ messages,
+ duplicateThreshold,
+ currentDeps,
+ lockfilePath,
+ parsedCurrentLock
+ );
await scanForDependencySize(
messages,
sizeThreshold,
diff --git a/package-lock.json b/package-lock.json
index afa77df..674eb7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"@types/node": "^24.9.0",
"esbuild": "^0.25.11",
"eslint": "^9.38.0",
- "lockparse": "^0.3.0",
+ "lockparse": "^0.5.0",
"module-replacements": "^2.9.0",
"pkg-types": "^2.3.0",
"prettier": "^3.6.2",
@@ -2517,9 +2517,9 @@
}
},
"node_modules/lockparse": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.3.0.tgz",
- "integrity": "sha512-k4wqfH56tmYzHsoWy0FPN2dKRrZJ+9XE2YMaAw6sffv+6Z1huxCWBsHw1UQkwzw9Y23NeXTILGmgMGRIh/1STA==",
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.5.0.tgz",
+ "integrity": "sha512-seaI91ZVc4mnEGL+/cEEd5MybTnb86NH3W5lM0Ft7CMCZsLP5z1orWnu8g7YacpiMc5GxU7wIrYLhb6W2DvNWg==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index ebbff5a..54a796c 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"@types/node": "^24.9.0",
"esbuild": "^0.25.11",
"eslint": "^9.38.0",
- "lockparse": "^0.3.0",
+ "lockparse": "^0.5.0",
"module-replacements": "^2.9.0",
"pkg-types": "^2.3.0",
"prettier": "^3.6.2",
diff --git a/src/checks/duplicates.ts b/src/checks/duplicates.ts
index 4326a4f..ed1cd7e 100644
--- a/src/checks/duplicates.ts
+++ b/src/checks/duplicates.ts
@@ -1,3 +1,5 @@
+import {type ParsedLockFile, traverse, type VisitorFn} from 'lockparse';
+
function getLsCommand(
lockfilePath: string,
packageName: string
@@ -17,22 +19,107 @@ function getLsCommand(
return undefined;
}
+function computeParentPaths(
+ lockfile: ParsedLockFile,
+ duplicateDependencyNames: Set,
+ dependencyMap: Map>
+): Map {
+ const parentPaths = new Map();
+
+ const visitorFn: VisitorFn = (node, _parent, path) => {
+ if (!duplicateDependencyNames.has(node.name) || !path) {
+ return;
+ }
+ const versionSet = dependencyMap.get(node.name);
+ if (!versionSet) {
+ return;
+ }
+ const nodeKey = `${node.name}@${node.version}`;
+ if (parentPaths.has(nodeKey)) {
+ return;
+ }
+ const parentPath = path.map((node) => `${node.name}@${node.version}`);
+ parentPaths.set(nodeKey, parentPath);
+ };
+ const visitor = {
+ dependency: visitorFn,
+ devDependency: visitorFn,
+ optionalDependency: visitorFn
+ };
+
+ traverse(lockfile.root, visitor);
+
+ return parentPaths;
+}
+
export function scanForDuplicates(
messages: string[],
threshold: number,
dependencyMap: Map>,
- lockfilePath: string
+ lockfilePath: string,
+ lockfile: ParsedLockFile
): void {
const duplicateRows: string[] = [];
+ const duplicateDependencyNames = new Set();
+
for (const [packageName, currentVersionSet] of dependencyMap) {
if (currentVersionSet.size > threshold) {
- const versions = Array.from(currentVersionSet).sort();
- duplicateRows.push(
- `| ${packageName} | ${currentVersionSet.size} versions | ${versions.join(', ')} |`
- );
+ duplicateDependencyNames.add(packageName);
}
}
+ if (duplicateDependencyNames.size === 0) {
+ return;
+ }
+
+ const parentPaths = computeParentPaths(
+ lockfile,
+ duplicateDependencyNames,
+ dependencyMap
+ );
+
+ for (const name of duplicateDependencyNames) {
+ const versionSet = dependencyMap.get(name);
+ if (!versionSet) {
+ continue;
+ }
+ const versions = Array.from(versionSet).sort();
+
+ const detailsLines: string[] = [];
+ for (const version of versions) {
+ const pathKey = `${name}@${version}`;
+ const pathArray = parentPaths.get(pathKey);
+ if (pathArray && pathArray.length > 0) {
+ const maxDepth = 6;
+ const totalDepth = pathArray.length + 1;
+
+ let displayPath: string[];
+ if (totalDepth > maxDepth) {
+ displayPath = [
+ ...pathArray.slice(0, 2),
+ '...',
+ ...pathArray.slice(-2)
+ ];
+ } else {
+ displayPath = pathArray;
+ }
+
+ let nestedList = `**${name}@${version}**`;
+ for (let i = displayPath.length - 1; i >= 0; i--) {
+ nestedList = `${displayPath[i]}`;
+ }
+ detailsLines.push(``);
+ } else {
+ detailsLines.push(`**${name}@${version}**`);
+ }
+ }
+
+ const detailsContent = detailsLines.join('
');
+ const collapsibleSection = `${versionSet.size} version${versionSet.size > 1 ? 's' : ''}
${detailsContent}
`;
+
+ duplicateRows.push(`| ${name} | ${collapsibleSection} |`);
+ }
+
if (duplicateRows.length > 0) {
const exampleCommand = getLsCommand(lockfilePath, 'example-package');
const helpMessage = exampleCommand
@@ -41,8 +128,8 @@ export function scanForDuplicates(
messages.push(
`## ⚠️ Duplicate Dependencies (threshold: ${threshold})
-| 📦 Package | 🔢 Version Count | 📋 Versions |
-| --- | --- | --- |
+| 📦 Package | 📋 Versions |
+| --- | --- |
${duplicateRows.join('\n')}${helpMessage}`
);
}
diff --git a/src/main.ts b/src/main.ts
index 6999bfc..9a16c86 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -143,7 +143,13 @@ async function run(): Promise {
currentDeps,
baseDeps
);
- scanForDuplicates(messages, duplicateThreshold, currentDeps, lockfilePath);
+ scanForDuplicates(
+ messages,
+ duplicateThreshold,
+ currentDeps,
+ lockfilePath,
+ parsedCurrentLock
+ );
await scanForDependencySize(
messages,
diff --git a/test/checks/__snapshots__/duplicates_test.ts.snap b/test/checks/__snapshots__/duplicates_test.ts.snap
new file mode 100644
index 0000000..96c0358
--- /dev/null
+++ b/test/checks/__snapshots__/duplicates_test.ts.snap
@@ -0,0 +1,25 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`scanForDuplicates > should report duplicates when threshold is exceeded 1`] = `
+[
+ "## ⚠️ Duplicate Dependencies (threshold: 1)
+
+| 📦 Package | 📋 Versions |
+| --- | --- |
+| package-a | 2 versions
|
+
+💡 To find out what depends on a specific package, run: \`npm ls example-package\`",
+]
+`;
+
+exports[`scanForDuplicates > should truncate long parent paths in the report 1`] = `
+[
+ "## ⚠️ Duplicate Dependencies (threshold: 1)
+
+| 📦 Package | 📋 Versions |
+| --- | --- |
+| package-a | 2 versions
|
+
+💡 To find out what depends on a specific package, run: \`npm ls example-package\`",
+]
+`;
diff --git a/test/checks/duplicates_test.ts b/test/checks/duplicates_test.ts
new file mode 100644
index 0000000..f133055
--- /dev/null
+++ b/test/checks/duplicates_test.ts
@@ -0,0 +1,212 @@
+import {describe, expect, it} from 'vitest';
+import {scanForDuplicates} from '../../src/checks/duplicates.js';
+import type {ParsedLockFile, ParsedDependency} from 'lockparse';
+
+describe('scanForDuplicates', () => {
+ it('should do nothing if no duplicates are found', () => {
+ const messages: string[] = [];
+ const threshold = 1;
+ const dependencyMap = new Map>([
+ ['package-a', new Set(['1.0.0'])],
+ ['package-b', new Set(['2.0.0'])]
+ ]);
+ const lockfilePath = 'package-lock.json';
+ const lockfile: ParsedLockFile = {
+ type: 'npm',
+ packages: [],
+ root: {
+ name: 'root-package',
+ version: '1.0.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ }
+ };
+
+ scanForDuplicates(
+ messages,
+ threshold,
+ dependencyMap,
+ lockfilePath,
+ lockfile
+ );
+
+ expect(messages).toHaveLength(0);
+ });
+
+ it('should report duplicates when threshold is exceeded', () => {
+ const messages: string[] = [];
+ const threshold = 1;
+ const dependencyMap = new Map>([
+ ['package-a', new Set(['1.0.0', '1.1.0'])],
+ ['package-b', new Set(['2.0.0'])]
+ ]);
+ const lockfilePath = 'package-lock.json';
+ const packageA: ParsedDependency = {
+ name: 'package-a',
+ version: '1.0.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const packageAAlt: ParsedDependency = {
+ name: 'package-a',
+ version: '1.1.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const packageB: ParsedDependency = {
+ name: 'package-b',
+ version: '2.0.0',
+ dependencies: [packageAAlt],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const lockfile: ParsedLockFile = {
+ type: 'npm',
+ packages: [packageA, packageAAlt, packageB],
+ root: {
+ name: 'root-package',
+ version: '1.0.0',
+ dependencies: [packageA, packageB],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ }
+ };
+
+ scanForDuplicates(
+ messages,
+ threshold,
+ dependencyMap,
+ lockfilePath,
+ lockfile
+ );
+
+ expect(messages).toMatchSnapshot();
+ });
+
+ it('should do nothing when duplicates are below threshold', () => {
+ const messages: string[] = [];
+ const threshold = 2;
+ const dependencyMap = new Map>([
+ ['package-a', new Set(['1.0.0', '1.1.0'])],
+ ['package-b', new Set(['2.0.0'])]
+ ]);
+ const lockfilePath = 'package-lock.json';
+ const packageA: ParsedDependency = {
+ name: 'package-a',
+ version: '1.0.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const packageAAlt: ParsedDependency = {
+ name: 'package-a',
+ version: '1.1.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const packageB: ParsedDependency = {
+ name: 'package-b',
+ version: '2.0.0',
+ dependencies: [packageAAlt],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const lockfile: ParsedLockFile = {
+ type: 'npm',
+ packages: [packageA, packageAAlt, packageB],
+ root: {
+ name: 'root-package',
+ version: '1.0.0',
+ dependencies: [packageA, packageB],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ }
+ };
+
+ scanForDuplicates(
+ messages,
+ threshold,
+ dependencyMap,
+ lockfilePath,
+ lockfile
+ );
+
+ expect(messages).toHaveLength(0);
+ });
+
+ it('should truncate long parent paths in the report', () => {
+ const messages: string[] = [];
+ const threshold = 1;
+ const dependencyMap = new Map>([
+ ['package-a', new Set(['1.0.0', '1.1.0'])]
+ ]);
+ const lockfilePath = 'package-lock.json';
+ const longPath: ParsedDependency[] = [];
+ for (let i = 0; i < 20; i++) {
+ longPath.push({
+ name: `package-${i}`,
+ version: '1.0.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ });
+ }
+ const packageA: ParsedDependency = {
+ name: 'package-a',
+ version: '1.0.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ const packageAAlt: ParsedDependency = {
+ name: 'package-a',
+ version: '1.1.0',
+ dependencies: [],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ };
+ for (let i = longPath.length - 1; i > 0; i--) {
+ longPath[i - 1].dependencies.push(longPath[i]);
+ }
+ longPath[longPath.length - 1].dependencies.push(packageAAlt);
+
+ const lockfile: ParsedLockFile = {
+ type: 'npm',
+ packages: [packageA, packageAAlt, ...longPath],
+ root: {
+ name: 'root-package',
+ version: '1.0.0',
+ dependencies: [packageA, longPath[0]],
+ devDependencies: [],
+ optionalDependencies: [],
+ peerDependencies: []
+ }
+ };
+
+ scanForDuplicates(
+ messages,
+ threshold,
+ dependencyMap,
+ lockfilePath,
+ lockfile
+ );
+
+ expect(messages).toMatchSnapshot();
+ });
+});