From 19d2cd6f2b64773082b107891abb6be0a15c9dd8 Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 12:44:55 +0000 Subject: [PATCH 1/9] WIP: remove old duplicate dependency code --- src/analyze/dependencies.ts | 236 +----------------------- src/analyze/report.ts | 1 - src/test/analyze/dependencies.test.ts | 1 - src/test/custom-manifests.test.ts | 1 - src/test/duplicate-dependencies.test.ts | 1 - src/types.ts | 1 - 6 files changed, 6 insertions(+), 235 deletions(-) diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index 4e05926..1b55dc2 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -1,4 +1,3 @@ -import colors from 'picocolors'; import {analyzePackageModuleType} from '../compute-type.js'; import type { PackageJsonLike, @@ -10,128 +9,7 @@ import type { import type {FileSystem} from '../file-system.js'; import {normalizePath} from '../utils/path.js'; -interface DependencyNode { - name: string; - version: string; - // TODO (43081j): make this an array or something structured one day - path: string; // Path in dependency tree (e.g., "root > package-a > package-b") - parent?: string; // Parent package name - depth: number; // Depth in dependency tree - packagePath: string; // File system path to package.json -} - -interface DuplicateDependency { - name: string; - versions: DependencyNode[]; - severity: 'exact' | 'conflict' | 'resolvable'; - potentialSavings?: number; - suggestions?: string[]; -} - -/** - * Detects duplicate dependencies from a list of dependency nodes - */ -function detectDuplicates( - dependencyNodes: DependencyNode[] -): DuplicateDependency[] { - const duplicates: DuplicateDependency[] = []; - const packageGroups = new Map(); - - // Group dependencies by name - for (const node of dependencyNodes) { - if (!packageGroups.has(node.name)) { - packageGroups.set(node.name, []); - } - packageGroups.get(node.name)?.push(node); - } - - // Find packages with multiple versions - for (const [packageName, nodes] of packageGroups) { - if (nodes.length > 1) { - const duplicate = analyzeDuplicate(packageName, nodes); - if (duplicate) { - duplicates.push(duplicate); - } - } - } - - return duplicates; -} - -/** - * Analyzes a group of nodes for the same package to determine duplicate type - */ -function analyzeDuplicate( - packageName: string, - nodes: DependencyNode[] -): DuplicateDependency | null { - // Skip root package - if (packageName === 'root' || nodes.some((n) => n.name === 'root')) { - return null; - } - - const uniqueVersions = new Set(nodes.map((n) => n.version)); - - let severity: 'exact' | 'conflict' | 'resolvable'; - - // If more than one version, it's a conflict - if (uniqueVersions.size === 1) { - severity = 'exact'; - } else { - severity = 'conflict'; - } - - return { - name: packageName, - versions: nodes, - severity, - potentialSavings: calculatePotentialSavings(nodes), - suggestions: generateSuggestions(nodes) - }; -} - -/** - * Calculates potential savings from deduplication - */ -function calculatePotentialSavings(nodes: DependencyNode[]): number { - // For now, return a simple estimate based on number of duplicates - // TODO: Implement actual size calculation - return nodes.length - 1; -} - -/** - * Generates suggestions for resolving duplicates - */ -function generateSuggestions(nodes: DependencyNode[]): string[] { - const suggestions: string[] = []; - - // Group by version to identify the most common version - const versionCounts = new Map(); - for (const node of nodes) { - versionCounts.set(node.version, (versionCounts.get(node.version) || 0) + 1); - } - - const mostCommonVersion = Array.from(versionCounts.entries()).sort( - (a, b) => b[1] - a[1] - )[0]; - - if (mostCommonVersion && mostCommonVersion[1] > 1) { - suggestions.push( - `Consider standardizing on version ${mostCommonVersion[0]} (used by ${mostCommonVersion[1]} dependencies)` - ); - } - - // Suggest checking for newer versions of consuming packages - const uniqueParents = new Set(nodes.map((n) => n.parent).filter(Boolean)); - if (uniqueParents.size > 1) { - suggestions.push( - `Check if newer versions of consuming packages (${Array.from(uniqueParents).join(', ')}) would resolve this duplicate` - ); - } - - return suggestions; -} - +// TODO Move this to a utilities file /** * Attempts to parse a `package.json` file */ @@ -146,16 +24,13 @@ async function parsePackageJson( } } -// Keep the existing tarball analysis for backward compatibility export async function runDependencyAnalysis( context: AnalysisContext ): Promise { const packageFiles = await context.fs.listPackageFiles(); - const rootDir = await context.fs.getRootDir(); - const messages: Message[] = []; - // Find root package.json - const pkg = await parsePackageJson(context.fs, '/package.json'); + const messages: Message[] = []; + const pkg = context.packageFile; if (!pkg) { throw new Error('No package.json found.'); @@ -172,35 +47,22 @@ export async function runDependencyAnalysis( production: prodDependencies, development: devDependencies, esm: 0, - cjs: 0, - duplicate: 0 + cjs: 0 } }; let cjsDependencies = 0; let esmDependencies = 0; - const dependencyNodes: DependencyNode[] = []; // Recursively traverse dependencies async function traverse( packagePath: string, - parent: string | undefined, depth: number, pathInTree: string ) { const depPkg = await parsePackageJson(context.fs, packagePath); if (!depPkg || !depPkg.name) return; - // Record this node - dependencyNodes.push({ - name: depPkg.name, - version: depPkg.version || 'unknown', - path: pathInTree, - parent, - depth, - packagePath - }); - // Only count CJS/ESM for non-root packages if (depth > 0) { const type = analyzePackageModuleType(depPkg); @@ -230,102 +92,16 @@ export async function runDependencyAnalysis( } if (packageMatch) { - await traverse( - packageMatch, - depPkg.name, - depth + 1, - pathInTree + ' > ' + depName - ); + await traverse(packageMatch, depth + 1, pathInTree + ' > ' + depName); } } } // Start traversal from root - await traverse('/package.json', undefined, 0, 'root'); - - // Collect all dependency instances for duplicate detection - // This ensures we find all versions, even those in nested node_modules - // TODO (43081j): don't do this. we're re-traversing most files just to - // find the ones that don't exist in the parent package's dependency list. - // there must be a better way - for (const file of packageFiles) { - const rootPackageJsonPath = normalizePath(rootDir) + '/package.json'; - if (file === rootPackageJsonPath) { - continue; - } - - try { - const depPkg = await parsePackageJson(context.fs, file); - if (!depPkg || !depPkg.name) { - continue; - } - - // Check if we already have this exact package in our dependency nodes - const alreadyExists = dependencyNodes.some( - (node) => node.packagePath === file - ); - - if (!alreadyExists) { - // Extract path information from the file path - const normalizedFile = normalizePath(file); - const pathParts = normalizedFile.split('/node_modules/'); - if (pathParts.length > 1) { - const packageDirName = pathParts[pathParts.length - 1].replace( - '/package.json', - '' - ); - const parentDirName = pathParts[pathParts.length - 2] - ?.split('/') - .pop(); - - dependencyNodes.push({ - name: depPkg.name, - version: depPkg.version || 'unknown', - path: packageDirName, - parent: parentDirName, - depth: pathParts.length - 1, - packagePath: file - }); - } - } - } catch { - // Skip invalid package.json files - } - } - - // Detect duplicates from the collected dependency nodes - const duplicateDependencies = detectDuplicates(dependencyNodes); + await traverse('/package.json', 0, 'root'); stats.dependencyCount.cjs = cjsDependencies; stats.dependencyCount.esm = esmDependencies; - if (duplicateDependencies.length > 0) { - stats.dependencyCount.duplicate = duplicateDependencies.length; - - for (const duplicate of duplicateDependencies) { - const severityColor = - duplicate.severity === 'exact' ? colors.blue : colors.yellow; - - let message = `${severityColor('[duplicate dependency]')} ${colors.bold(duplicate.name)} has ${duplicate.versions.length} installed versions:`; - - for (const version of duplicate.versions) { - message += `\n ${colors.gray(version.version)} via ${colors.gray(version.path)}`; - } - - if (duplicate.suggestions && duplicate.suggestions.length > 0) { - message += '\nSuggestions:'; - for (const suggestion of duplicate.suggestions) { - message += ` ${colors.blue('💡')} ${colors.gray(suggestion)}`; - } - } - - messages.push({ - message, - severity: 'warning', - score: 0 - }); - } - } - return {stats, messages}; } diff --git a/src/analyze/report.ts b/src/analyze/report.ts index fb8670d..35ebe62 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -49,7 +49,6 @@ export async function report(options: Options) { production: 0, development: 0, cjs: 0, - duplicate: 0, esm: 0 }, extraStats diff --git a/src/test/analyze/dependencies.test.ts b/src/test/analyze/dependencies.test.ts index c49fdfc..3ae010b 100644 --- a/src/test/analyze/dependencies.test.ts +++ b/src/test/analyze/dependencies.test.ts @@ -30,7 +30,6 @@ describe('analyzeDependencies (local)', () => { dependencyCount: { cjs: 0, esm: 0, - duplicate: 0, production: 0, development: 0 }, diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts index 574401f..588404f 100644 --- a/src/test/custom-manifests.test.ts +++ b/src/test/custom-manifests.test.ts @@ -25,7 +25,6 @@ describe('Custom Manifests', () => { dependencyCount: { cjs: 0, esm: 0, - duplicate: 0, production: 0, development: 0 }, diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index b0b0751..54be72e 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -30,7 +30,6 @@ describe('Duplicate Dependency Detection', () => { dependencyCount: { production: 0, development: 0, - duplicate: 0, esm: 0, cjs: 0 }, diff --git a/src/types.ts b/src/types.ts index c0a1f27..72b96b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,6 @@ export interface Stats { development: number; cjs: number; esm: number; - duplicate: number; }; extraStats?: Stat[]; } From 6a21b3c86ebd1094ce44f258bfbbcf19563e9d4b Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 12:52:41 +0000 Subject: [PATCH 2/9] use utility function --- src/analyze/dependencies.ts | 22 +++------------------- src/utils/package-json.ts | 5 +++-- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index 1b55dc2..e163169 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -1,28 +1,12 @@ import {analyzePackageModuleType} from '../compute-type.js'; import type { - PackageJsonLike, ReportPluginResult, Message, Stats, AnalysisContext } from '../types.js'; -import type {FileSystem} from '../file-system.js'; import {normalizePath} from '../utils/path.js'; - -// TODO Move this to a utilities file -/** - * Attempts to parse a `package.json` file - */ -async function parsePackageJson( - fileSystem: FileSystem, - path: string -): Promise { - try { - return JSON.parse(await fileSystem.readFile(path)); - } catch { - return null; - } -} +import {getPackageJson} from '../utils/package-json.js'; export async function runDependencyAnalysis( context: AnalysisContext @@ -60,7 +44,7 @@ export async function runDependencyAnalysis( depth: number, pathInTree: string ) { - const depPkg = await parsePackageJson(context.fs, packagePath); + const depPkg = await getPackageJson(context.fs, packagePath); if (!depPkg || !depPkg.name) return; // Only count CJS/ESM for non-root packages @@ -83,7 +67,7 @@ export async function runDependencyAnalysis( if (!packageMatch) { for (const packageFile of packageFiles) { - const depPkg = await parsePackageJson(context.fs, packageFile); + const depPkg = await getPackageJson(context.fs, packageFile); if (depPkg !== null && depPkg.name === depName) { packageMatch = packageFile; break; diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index 09ca0e0..f8d50a0 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -4,12 +4,13 @@ import type {FileSystem} from '../file-system.js'; import type {PackageJsonLike} from '../types.js'; export async function getPackageJson( - fileSystem: FileSystem + fileSystem: FileSystem, + path: string = '/package.json' ): Promise { let packageJsonText: string; try { - packageJsonText = await fileSystem.readFile('/package.json'); + packageJsonText = await fileSystem.readFile(path); } catch { // No package.json found return null; From f302a1f843c152d141bb8a65c578a6283071dc7d Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 16:54:55 +0000 Subject: [PATCH 3/9] update the tests --- .../duplicate-dependencies.test.ts.snap | 92 +----- .../__snapshots__/dependencies.test.ts.snap | 4 - src/test/analyze/dependencies.test.ts | 14 + src/test/duplicate-dependencies.test.ts | 271 ++++++++---------- 4 files changed, 144 insertions(+), 237 deletions(-) diff --git a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap index 1b1f9f6..af9d54f 100644 --- a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap +++ b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap @@ -1,101 +1,25 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Duplicate Dependency Detection > should detect exact duplicate dependencies 1`] = ` +exports[`Duplicate Dependency Detection > should detect multiple versions 1`] = ` { "messages": [ { - "message": "[duplicate dependency] shared-lib has 2 installed versions: - 2.0.0 via root > package-a > shared-lib - 2.0.0 via root > package-b > shared-lib -Suggestions: 💡 Consider standardizing on version 2.0.0 (used by 2 dependencies) 💡 Check if newer versions of consuming packages (package-a, package-b) would resolve this duplicate", + "message": "[duplicate dependency] shared-lib has 2 installed versions: +1.0.0 via the following 2 package(s) package-a@1.0.0, package-c@1.0.0 +2.0.0 via the following 1 package(s) package-b@1.0.0 +💡 Suggestions +- Consider standardizing on version 1.0.0 as this version is the most commonly used. +- Consider upgrading consuming packages as this may resolve this duplicate version. +", "score": 0, "severity": "warning", }, ], - "stats": { - "dependencyCount": { - "cjs": 4, - "development": 0, - "duplicate": 1, - "esm": 0, - "production": 2, - }, - "installSize": 1151, - "name": "test-package", - "version": "1.0.0", - }, -} -`; - -exports[`Duplicate Dependency Detection > should detect version conflicts 1`] = ` -{ - "messages": [ - { - "message": "[duplicate dependency] shared-lib has 3 installed versions: - 1.0.0 via root > package-a > shared-lib - 1.0.0 via root > package-b > shared-lib - 2.0.0 via shared-lib -Suggestions: 💡 Consider standardizing on version 1.0.0 (used by 2 dependencies) 💡 Check if newer versions of consuming packages (package-a, package-b) would resolve this duplicate", - "score": 0, - "severity": "warning", - }, - ], - "stats": { - "dependencyCount": { - "cjs": 4, - "development": 0, - "duplicate": 1, - "esm": 0, - "production": 2, - }, - "installSize": 1416, - "name": "test-package", - "version": "1.0.0", - }, -} -`; - -exports[`Duplicate Dependency Detection > should generate suggestions for duplicates 1`] = ` -{ - "messages": [ - { - "message": "[duplicate dependency] shared-lib has 2 installed versions: - 2.0.0 via root > package-a > shared-lib - 2.0.0 via root > package-b > shared-lib -Suggestions: 💡 Consider standardizing on version 2.0.0 (used by 2 dependencies) 💡 Check if newer versions of consuming packages (package-a, package-b) would resolve this duplicate", - "score": 0, - "severity": "warning", - }, - ], - "stats": { - "dependencyCount": { - "cjs": 4, - "development": 0, - "duplicate": 1, - "esm": 0, - "production": 2, - }, - "installSize": 1151, - "name": "test-package", - "version": "1.0.0", - }, } `; exports[`Duplicate Dependency Detection > should not detect duplicates when there are none 1`] = ` { "messages": [], - "stats": { - "dependencyCount": { - "cjs": 1, - "development": 0, - "duplicate": 0, - "esm": 0, - "production": 1, - }, - "installSize": 262, - "name": "test-package", - "version": "1.0.0", - }, } `; diff --git a/src/test/analyze/__snapshots__/dependencies.test.ts.snap b/src/test/analyze/__snapshots__/dependencies.test.ts.snap index 87613df..55f7fde 100644 --- a/src/test/analyze/__snapshots__/dependencies.test.ts.snap +++ b/src/test/analyze/__snapshots__/dependencies.test.ts.snap @@ -7,7 +7,6 @@ exports[`analyzeDependencies (local) > should analyze dependencies correctly 1`] "dependencyCount": { "cjs": 1, "development": 1, - "duplicate": 0, "esm": 1, "production": 2, }, @@ -25,7 +24,6 @@ exports[`analyzeDependencies (local) > should handle empty project 1`] = ` "dependencyCount": { "cjs": 0, "development": 0, - "duplicate": 0, "esm": 0, "production": 0, }, @@ -43,7 +41,6 @@ exports[`analyzeDependencies (local) > should handle missing node_modules 1`] = "dependencyCount": { "cjs": 0, "development": 0, - "duplicate": 0, "esm": 0, "production": 1, }, @@ -61,7 +58,6 @@ exports[`analyzeDependencies (local) > should handle symlinks 1`] = ` "dependencyCount": { "cjs": 0, "development": 0, - "duplicate": 0, "esm": 1, "production": 1, }, diff --git a/src/test/analyze/dependencies.test.ts b/src/test/analyze/dependencies.test.ts index 3ae010b..267156d 100644 --- a/src/test/analyze/dependencies.test.ts +++ b/src/test/analyze/dependencies.test.ts @@ -104,6 +104,12 @@ describe('analyzeDependencies (local)', () => { type: 'commonjs' } ]; + //update package json on context + context.packageFile.dependencies = { + 'cjs-package': '1.0.0', + 'esm-package': '1.0.0' + }; + context.packageFile.devDependencies = {'dev-package': '1.0.0'}; await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); @@ -112,6 +118,10 @@ describe('analyzeDependencies (local)', () => { }); it('should handle symlinks', async () => { + //update package json on context + context.packageFile.dependencies = { + 'test-package': '1.0.0' + }; // Create root package await createTestPackage( tempDir, @@ -146,6 +156,10 @@ describe('analyzeDependencies (local)', () => { }); it('should handle missing node_modules', async () => { + //update package json on context + context.packageFile.dependencies = { + 'test-package': '1.0.0' + }; await createTestPackage(tempDir, { name: 'test-package', version: '1.0.0', diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index 54be72e..398bc49 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -1,16 +1,14 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; -import {runDependencyAnalysis} from '../analyze/dependencies.js'; import {LocalFileSystem} from '../local-file-system.js'; import { createTempDir, cleanupTempDir, - createTestPackage, createTestPackageWithDependencies, type TestPackage } from './utils.js'; import type {AnalysisContext} from '../types.js'; -import fs from 'node:fs/promises'; -import path from 'node:path'; +import {runDuplicateDependencyAnalysis} from '../analyze/duplicate-dependencies.js'; +import {ParsedDependency} from 'lockparse'; describe('Duplicate Dependency Detection', () => { let tempDir: string; @@ -20,6 +18,62 @@ describe('Duplicate Dependency Detection', () => { beforeEach(async () => { tempDir = await createTempDir(); fileSystem = new LocalFileSystem(tempDir); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('should detect multiple versions', async () => { + const sharedLibv1: ParsedDependency = { + name: 'shared-lib', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const sharedLibv2: ParsedDependency = { + name: 'shared-lib', + version: '2.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + const packageA: ParsedDependency = { + name: 'package-a', + version: '1.0.0', + dependencies: [sharedLibv1], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const packageB: ParsedDependency = { + name: 'package-b', + version: '1.0.0', + dependencies: [sharedLibv2], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const packageC: ParsedDependency = { + name: 'package-c', + version: '1.0.0', + dependencies: [sharedLibv1], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const testPkg: ParsedDependency = { + name: 'test-package', + version: '1.0.0', + dependencies: [packageA, packageB, packageC], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + }; + //set the context context = { fs: fileSystem, root: '.', @@ -37,11 +91,18 @@ describe('Duplicate Dependency Detection', () => { }, lockfile: { type: 'npm', - packages: [], + packages: [ + testPkg, + packageA, + packageB, + packageC, + sharedLibv1, + sharedLibv2 + ], root: { - name: 'test-package', + name: 'root-package', version: '1.0.0', - dependencies: [], + dependencies: [testPkg], devDependencies: [], optionalDependencies: [], peerDependencies: [] @@ -52,169 +113,81 @@ describe('Duplicate Dependency Detection', () => { version: '1.0.0' } }; - }); - - afterEach(async () => { - await cleanupTempDir(tempDir); - }); - - it('should detect exact duplicate dependencies', async () => { - const rootPackage: TestPackage = { - name: 'test-package', - version: '1.0.0', - dependencies: { - 'package-a': '1.0.0', - 'package-b': '1.0.0' - } - }; - - const dependencies: TestPackage[] = [ - { - name: 'package-a', - version: '1.0.0', - dependencies: { - 'shared-lib': '2.0.0' - } - }, - { - name: 'package-b', - version: '1.0.0', - dependencies: { - 'shared-lib': '2.0.0' - } - }, - { - name: 'shared-lib', - version: '2.0.0' - } - ]; - await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - - const stats = await runDependencyAnalysis(context); + const stats = await runDuplicateDependencyAnalysis(context); expect(stats).toMatchSnapshot(); }); - it('should detect version conflicts', async () => { - const rootPackage: TestPackage = { - name: 'test-package', + it('should not detect duplicates when there are none', async () => { + const sharedLibv1: ParsedDependency = { + name: 'shared-lib', version: '1.0.0', - dependencies: { - 'package-a': '1.0.0', - 'package-b': '1.0.0' - } + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] }; - // Create root package - await createTestPackage(tempDir, rootPackage, {createNodeModules: true}); - - // Create package-a with shared-lib v1.0.0 - const packageADir = path.join(tempDir, 'node_modules', 'package-a'); - await fs.mkdir(packageADir); - await createTestPackage(packageADir, { + const packageA: ParsedDependency = { name: 'package-a', version: '1.0.0', - dependencies: { - 'shared-lib': '1.0.0' - } - }); - - // Create package-b with shared-lib v2.0.0 - const packageBDir = path.join(tempDir, 'node_modules', 'package-b'); - await fs.mkdir(packageBDir); - await createTestPackage(packageBDir, { + dependencies: [sharedLibv1], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] + }; + const packageB: ParsedDependency = { name: 'package-b', version: '1.0.0', - dependencies: { - 'shared-lib': '2.0.0' - } - }); - - // Create shared-lib v1.0.0 - const sharedLibV1Dir = path.join(tempDir, 'node_modules', 'shared-lib'); - await fs.mkdir(sharedLibV1Dir); - await createTestPackage(sharedLibV1Dir, { - name: 'shared-lib', - version: '1.0.0' - }); - - // Create shared-lib v2.0.0 in a nested location - const sharedLibV2Dir = path.join( - tempDir, - 'node_modules', - 'package-b', - 'node_modules', - 'shared-lib' - ); - await fs.mkdir(sharedLibV2Dir, {recursive: true}); - await createTestPackage(sharedLibV2Dir, { - name: 'shared-lib', - version: '2.0.0' - }); - - const stats = await runDependencyAnalysis(context); - - expect(stats).toMatchSnapshot(); - }); - - it('should not detect duplicates when there are none', async () => { - const rootPackage: TestPackage = { - name: 'test-package', - version: '1.0.0', - dependencies: { - 'package-a': '1.0.0' - } + dependencies: [sharedLibv1], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [] }; - - const dependencies: TestPackage[] = [ - { - name: 'package-a', - version: '1.0.0' - } - ]; - - await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - - const stats = await runDependencyAnalysis(context); - - expect(stats).toMatchSnapshot(); - }); - - it('should generate suggestions for duplicates', async () => { - const rootPackage: TestPackage = { + const testPkg: ParsedDependency = { name: 'test-package', version: '1.0.0', - dependencies: { - 'package-a': '1.0.0', - 'package-b': '1.0.0' - } + dependencies: [packageA, packageB], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] }; - - const dependencies: TestPackage[] = [ - { - name: 'package-a', - version: '1.0.0', - dependencies: { - 'shared-lib': '2.0.0' - } + //set the context + context = { + fs: fileSystem, + root: '.', + messages: [], + stats: { + name: 'unknown', + version: 'unknown', + dependencyCount: { + production: 0, + development: 0, + esm: 0, + cjs: 0 + }, + extraStats: [] }, - { - name: 'package-b', - version: '1.0.0', - dependencies: { - 'shared-lib': '2.0.0' + lockfile: { + type: 'npm', + packages: [testPkg, packageA, packageB, sharedLibv1], + root: { + name: 'root-package', + version: '1.0.0', + dependencies: [testPkg], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] } }, - { - name: 'shared-lib', - version: '2.0.0' + packageFile: { + name: 'test-package', + version: '1.0.0' } - ]; - - await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); + }; - const stats = await runDependencyAnalysis(context); + const stats = await runDuplicateDependencyAnalysis(context); expect(stats).toMatchSnapshot(); }); From b5d30f1b049a97c22a1686628db875e899925cce Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:07:22 +0000 Subject: [PATCH 4/9] Add duplicate dependency count to the stats --- src/analyze/duplicate-dependencies.ts | 14 ++++++++++++-- .../duplicate-dependencies.test.ts.snap | 9 +++++++++ src/test/duplicate-dependencies.test.ts | 7 +------ src/types.ts | 2 +- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/analyze/duplicate-dependencies.ts b/src/analyze/duplicate-dependencies.ts index 1343b34..f8eda91 100644 --- a/src/analyze/duplicate-dependencies.ts +++ b/src/analyze/duplicate-dependencies.ts @@ -1,6 +1,6 @@ import colors from 'picocolors'; import {ParsedLockFile, traverse, VisitorFn} from 'lockparse'; -import {AnalysisContext, Message, ReportPluginResult} from '../types.js'; +import {AnalysisContext, Message, ReportPluginResult, Stats} from '../types.js'; interface Version { version: string; @@ -133,7 +133,17 @@ function exportOutput(duplicateDependencies: Map) { }); } - return {messages}; + const stats: Partial = { + extraStats: [ + { + name: 'duplicateDependencyCount', + value: duplicateDependencies.size, + label: 'Duplicate Dependency Count' + } + ] + }; + + return {stats, messages}; } /** diff --git a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap index af9d54f..a0c5448 100644 --- a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap +++ b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap @@ -15,6 +15,15 @@ exports[`Duplicate Dependency Detection > should detect multiple versions 1`] = "severity": "warning", }, ], + "stats": { + "extraStats": [ + { + "label": "Duplicate Dependency Count", + "name": "duplicateDependencyCount", + "value": 1, + }, + ], + }, } `; diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index 398bc49..ce0404e 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -1,11 +1,6 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; import {LocalFileSystem} from '../local-file-system.js'; -import { - createTempDir, - cleanupTempDir, - createTestPackageWithDependencies, - type TestPackage -} from './utils.js'; +import {createTempDir, cleanupTempDir} from './utils.js'; import type {AnalysisContext} from '../types.js'; import {runDuplicateDependencyAnalysis} from '../analyze/duplicate-dependencies.js'; import {ParsedDependency} from 'lockparse'; diff --git a/src/types.ts b/src/types.ts index 72b96b4..4cb3ff9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,7 +53,7 @@ export interface Replacement { } export interface ReportPluginResult { - stats?: Stats; + stats?: Partial; messages: Message[]; } From 2228fcc10f07a04ad9a331813705c1b21686d259 Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:19:29 +0000 Subject: [PATCH 5/9] Tidy up the code --- src/analyze/dependencies.ts | 24 +++++++++++------------- src/analyze/report.ts | 25 ++++++++++++------------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index e163169..182af55 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -23,17 +23,6 @@ export async function runDependencyAnalysis( const installSize = await context.fs.getInstallSize(); const prodDependencies = Object.keys(pkg.dependencies || {}).length; const devDependencies = Object.keys(pkg.devDependencies || {}).length; - const stats: Stats = { - name: pkg.name, - version: pkg.version, - installSize, - dependencyCount: { - production: prodDependencies, - development: devDependencies, - esm: 0, - cjs: 0 - } - }; let cjsDependencies = 0; let esmDependencies = 0; @@ -84,8 +73,17 @@ export async function runDependencyAnalysis( // Start traversal from root await traverse('/package.json', 0, 'root'); - stats.dependencyCount.cjs = cjsDependencies; - stats.dependencyCount.esm = esmDependencies; + const stats: Partial = { + name: pkg.name, + version: pkg.version, + installSize, + dependencyCount: { + production: prodDependencies, + development: devDependencies, + esm: esmDependencies, + cjs: cjsDependencies + } + }; return {stats, messages}; } diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 35ebe62..92d968d 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -5,7 +5,6 @@ import type {FileSystem} from '../file-system.js'; import type { Options, ReportPlugin, - Stat, Stats, Message, AnalysisContext @@ -41,18 +40,6 @@ async function computeInfo(fileSystem: FileSystem) { export async function report(options: Options) { const {root = process.cwd()} = options ?? {}; - const extraStats: Stat[] = []; - const stats: Stats = { - name: 'unknown', - version: 'unknown', - dependencyCount: { - production: 0, - development: 0, - cjs: 0, - esm: 0 - }, - extraStats - }; const messages: Message[] = []; const fileSystem = new LocalFileSystem(root); @@ -89,6 +76,18 @@ export async function report(options: Options) { packageFile ?? undefined ); + const stats: Stats = { + name: packageFile.name, + version: packageFile.version, + dependencyCount: { + production: 0, + development: 0, + cjs: 0, + esm: 0 + }, + extraStats: [] + }; + const context: AnalysisContext = { fs: fileSystem, root, From 75cdc772fd2eda1378863795208f1486822081df Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:41:50 +0000 Subject: [PATCH 6/9] move the code that adds the duplicate dependency count --- src/analyze/duplicate-dependencies.ts | 21 +++++++++---------- .../duplicate-dependencies.test.ts.snap | 9 ++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/analyze/duplicate-dependencies.ts b/src/analyze/duplicate-dependencies.ts index f8eda91..441ec76 100644 --- a/src/analyze/duplicate-dependencies.ts +++ b/src/analyze/duplicate-dependencies.ts @@ -105,8 +105,17 @@ async function computeParents( function exportOutput(duplicateDependencies: Map) { const messages: Message[] = []; + const stats: Partial = { + extraStats: [ + { + name: 'duplicateDependencyCount', + value: duplicateDependencies.size, + label: 'Duplicate Dependency Count' + } + ] + }; if (duplicateDependencies.size === 0) { - return {messages}; + return {stats, messages}; } for (const [packageName, duplicate] of duplicateDependencies) { @@ -133,16 +142,6 @@ function exportOutput(duplicateDependencies: Map) { }); } - const stats: Partial = { - extraStats: [ - { - name: 'duplicateDependencyCount', - value: duplicateDependencies.size, - label: 'Duplicate Dependency Count' - } - ] - }; - return {stats, messages}; } diff --git a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap index a0c5448..0298ac2 100644 --- a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap +++ b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap @@ -30,5 +30,14 @@ exports[`Duplicate Dependency Detection > should detect multiple versions 1`] = exports[`Duplicate Dependency Detection > should not detect duplicates when there are none 1`] = ` { "messages": [], + "stats": { + "extraStats": [ + { + "label": "Duplicate Dependency Count", + "name": "duplicateDependencyCount", + "value": 0, + }, + ], + }, } `; From 30c43baa63f1a328cd7ca75e1dc22b512174bdbf Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:43:05 +0000 Subject: [PATCH 7/9] update snapshot --- src/test/__snapshots__/cli.test.ts.snap | 54 +++++++++++++------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index 82bec78..a5b5981 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,19 +3,20 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ ES Modules 100% (1 ESM, 0 CJS) -│ -● Results: -│ -│ -└ Done! +T Analyzing... +| +• Summary +| Package Name mock-package +| Version 1.0.0 +| Install Size 53.0 B +| Dependencies 1 (1 production, 0 development) +| ES Modules 100% (1 ESM, 0 CJS) +| Duplicate Dependency Count 0 +| +• Results: +| +| +— Done! " `; @@ -25,19 +26,20 @@ exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ ES Modules 100% (1 ESM, 0 CJS) -│ -● Results: -│ -│ -└ Done! +T Analyzing... +| +• Summary +| Package Name mock-package +| Version 1.0.0 +| Install Size 53.0 B +| Dependencies 1 (1 production, 0 development) +| ES Modules 100% (1 ESM, 0 CJS) +| Duplicate Dependency Count 0 +| +• Results: +| +| +— Done! " `; From 4323c0d2225c673369ffb5e787fd5c3f6249ccd8 Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:55:08 +0000 Subject: [PATCH 8/9] update snapshot --- src/test/__snapshots__/cli.test.ts.snap | 64 ++++++++++++++----------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index a5b5981..b308a77 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,43 +3,49 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -T Analyzing... -| -• Summary -| Package Name mock-package -| Version 1.0.0 -| Install Size 53.0 B -| Dependencies 1 (1 production, 0 development) -| ES Modules 100% (1 ESM, 0 CJS) -| Duplicate Dependency Count 0 -| -• Results: -| -| -— Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ ES Modules 100% (1 ESM, 0 CJS) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should display package report 2`] = `""`; +exports[`CLI > should display package report 2`] = ` +"Debugger listening on ws://127.0.0.1:60355/7104db3a-6048-4275-b117-855e57a4546d +For help, see: https://nodejs.org/en/docs/inspector +Debugger attached. +Waiting for the debugger to disconnect... +" +`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -T Analyzing... -| -• Summary -| Package Name mock-package -| Version 1.0.0 -| Install Size 53.0 B -| Dependencies 1 (1 production, 0 development) -| ES Modules 100% (1 ESM, 0 CJS) -| Duplicate Dependency Count 0 -| -• Results: -| -| -— Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ ES Modules 100% (1 ESM, 0 CJS) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; From cdde4bc64bac5a1beecfa771560a544a923694b8 Mon Sep 17 00:00:00 2001 From: laurathackray Date: Fri, 16 Jan 2026 17:58:16 +0000 Subject: [PATCH 9/9] update snapshot --- src/test/__snapshots__/cli.test.ts.snap | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index b308a77..dae0f6a 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -21,13 +21,7 @@ exports[`CLI > should display package report 1`] = ` " `; -exports[`CLI > should display package report 2`] = ` -"Debugger listening on ws://127.0.0.1:60355/7104db3a-6048-4275-b117-855e57a4546d -For help, see: https://nodejs.org/en/docs/inspector -Debugger attached. -Waiting for the debugger to disconnect... -" -`; +exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli )