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]}
      ${nestedList}
  • `; + } + detailsLines.push(`
      ${nestedList}
    `); + } 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
    • root-package@1.0.0
      • **package-a@1.0.0**

    • root-package@1.0.0
      • package-b@2.0.0
        • **package-a@1.1.0**

    | + +💡 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
    • root-package@1.0.0
      • **package-a@1.0.0**

    • root-package@1.0.0
      • package-0@1.0.0
        • ...
          • package-18@1.0.0
            • package-19@1.0.0
              • **package-a@1.1.0**

    | + +💡 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(); + }); +});