diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..3532a96 --- /dev/null +++ b/index.ts @@ -0,0 +1,17 @@ +import { runDependencyAnalysis as runOldAnalysis } from './src/analyze/dependencies_old.js'; +import { runDependencyAnalysis as runNewAnalysis } from './src/analyze/dependencies_one_more_time.js'; +import { LocalFileSystem } from './src/local-file-system.js'; + +async function run() { + const fileSystem = new LocalFileSystem(process.cwd()); + + // console.log('Running old algorithm...'); + // const oldResult = await runOldAnalysis(fileSystem); + // console.log('Old result:', oldResult.stats); + + console.log('\nRunning new algorithm...'); + const newResult = await runNewAnalysis(fileSystem); + console.log('New result:', newResult.stats); +} + +run().catch(console.error); diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index 4e0810a..3ef7708 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -6,7 +6,7 @@ import type { Message, Stats } from '../types.js'; -import {FileSystem} from '../file-system.js'; +import type {FileSystem} from '../file-system.js'; import {normalizePath} from '../utils/path.js'; interface DependencyNode { @@ -153,6 +153,22 @@ export async function runDependencyAnalysis( const rootDir = await fileSystem.getRootDir(); const messages: Message[] = []; + const rootDirNorm = normalizePath(rootDir); + const virtualToRaw = new Map(); + const packageFilesVirtual = new Set(); + for (const raw of packageFiles) { + const rawNorm = normalizePath(raw); + let virtual = rawNorm; + if (virtual.startsWith(rootDirNorm + '/')) { + virtual = virtual.slice(rootDirNorm.length + 1); + } + if (virtual.startsWith('/')) { + virtual = virtual.slice(1); + } + packageFilesVirtual.add(virtual); + virtualToRaw.set(virtual, rawNorm); + } + // Find root package.json const pkg = await parsePackageJson(fileSystem, '/package.json'); @@ -182,12 +198,13 @@ export async function runDependencyAnalysis( // Recursively traverse dependencies async function traverse( - packagePath: string, + packagePathRaw: string, + packagePathVirtual: string, parent: string | undefined, depth: number, pathInTree: string ) { - const depPkg = await parsePackageJson(fileSystem, packagePath); + const depPkg = await parsePackageJson(fileSystem, packagePathRaw); if (!depPkg || !depPkg.name) return; // Record this node @@ -197,7 +214,7 @@ export async function runDependencyAnalysis( path: pathInTree, parent, depth, - packagePath + packagePath: packagePathRaw }); // Only count CJS/ESM for non-root packages @@ -212,89 +229,62 @@ export async function runDependencyAnalysis( } // Traverse dependencies - const allDeps = {...depPkg.dependencies, ...depPkg.devDependencies}; - for (const depName of Object.keys(allDeps)) { - let packageMatch = packageFiles.find((packageFile) => - normalizePath(packageFile).endsWith( - `/node_modules/${depName}/package.json` - ) - ); - - if (!packageMatch) { - for (const packageFile of packageFiles) { - const depPkg = await parsePackageJson(fileSystem, packageFile); - if (depPkg !== null && depPkg.name === depName) { - packageMatch = packageFile; - break; - } - } - } - - if (packageMatch) { - await traverse( - packageMatch, - depPkg.name, - depth + 1, - pathInTree + ' > ' + depName - ); - } + const allDeps = { + ...(depPkg.dependencies || {}), + ...(depPkg.devDependencies || {}) + }; + + const visited = new Set(); + + function dirOfVirtual(p: string): string { + if (p.endsWith('/package.json')) + return p.slice(0, -'/package.json'.length); + if (p === 'package.json') return ''; + const idx = p.lastIndexOf('/'); + return idx === -1 ? '' : p.slice(0, idx); } - } - // 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; + function parentDirVirtual(dir: string): string { + const idx = dir.lastIndexOf('/'); + return idx === -1 ? '' : dir.slice(0, idx); } - try { - const depPkg = await parsePackageJson(fileSystem, file); - if (!depPkg || !depPkg.name) { - continue; + function resolveDepVirtual( + depName: string, + fromVirtual: string + ): string | undefined { + let cur = dirOfVirtual(fromVirtual); + while (true) { + const prefix = cur ? cur + '/' : ''; + const candidate = `${prefix}node_modules/${depName}/package.json`; + if (packageFilesVirtual.has(candidate)) return candidate; + if (cur === '') break; + cur = parentDirVirtual(cur); } + return undefined; + } - // Check if we already have this exact package in our dependency nodes - const alreadyExists = dependencyNodes.some( - (node) => node.packagePath === file + for (const depName of Object.keys(allDeps)) { + const resolvedVirtual = resolveDepVirtual(depName, packagePathVirtual); + if (!resolvedVirtual) continue; + if (visited.has(resolvedVirtual)) continue; + visited.add(resolvedVirtual); + + const raw = virtualToRaw.get(resolvedVirtual) ?? resolvedVirtual; + await traverse( + raw, + resolvedVirtual, + depPkg.name, + depth + 1, + pathInTree + ' > ' + depName ); - - 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 + // Start traversal from root using '/package.json' and a normalized virtual 'package.json' + await traverse('/package.json', 'package.json', undefined, 0, 'root'); + + // Detect duplicates from collected nodes const duplicateDependencies = detectDuplicates(dependencyNodes); stats.dependencyCount.cjs = cjsDependencies; diff --git a/src/analyze/dependencies_new.ts b/src/analyze/dependencies_new.ts new file mode 100644 index 0000000..3ef7708 --- /dev/null +++ b/src/analyze/dependencies_new.ts @@ -0,0 +1,322 @@ +import colors from 'picocolors'; +import {analyzePackageModuleType} from '../compute-type.js'; +import type { + PackageJsonLike, + ReportPluginResult, + Message, + Stats +} from '../types.js'; +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; +} + +/** + * 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; + } +} + +// Keep the existing tarball analysis for backward compatibility +export async function runDependencyAnalysis( + fileSystem: FileSystem +): Promise { + const packageFiles = await fileSystem.listPackageFiles(); + const rootDir = await fileSystem.getRootDir(); + const messages: Message[] = []; + + const rootDirNorm = normalizePath(rootDir); + const virtualToRaw = new Map(); + const packageFilesVirtual = new Set(); + for (const raw of packageFiles) { + const rawNorm = normalizePath(raw); + let virtual = rawNorm; + if (virtual.startsWith(rootDirNorm + '/')) { + virtual = virtual.slice(rootDirNorm.length + 1); + } + if (virtual.startsWith('/')) { + virtual = virtual.slice(1); + } + packageFilesVirtual.add(virtual); + virtualToRaw.set(virtual, rawNorm); + } + + // Find root package.json + const pkg = await parsePackageJson(fileSystem, '/package.json'); + + if (!pkg) { + throw new Error('No package.json found.'); + } + + const installSize = await fileSystem.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, + duplicate: 0 + } + }; + + let cjsDependencies = 0; + let esmDependencies = 0; + const dependencyNodes: DependencyNode[] = []; + + // Recursively traverse dependencies + async function traverse( + packagePathRaw: string, + packagePathVirtual: string, + parent: string | undefined, + depth: number, + pathInTree: string + ) { + const depPkg = await parsePackageJson(fileSystem, packagePathRaw); + if (!depPkg || !depPkg.name) return; + + // Record this node + dependencyNodes.push({ + name: depPkg.name, + version: depPkg.version || 'unknown', + path: pathInTree, + parent, + depth, + packagePath: packagePathRaw + }); + + // Only count CJS/ESM for non-root packages + if (depth > 0) { + const type = analyzePackageModuleType(depPkg); + if (type === 'cjs') cjsDependencies++; + if (type === 'esm') esmDependencies++; + if (type === 'dual') { + cjsDependencies++; + esmDependencies++; + } + } + + // Traverse dependencies + const allDeps = { + ...(depPkg.dependencies || {}), + ...(depPkg.devDependencies || {}) + }; + + const visited = new Set(); + + function dirOfVirtual(p: string): string { + if (p.endsWith('/package.json')) + return p.slice(0, -'/package.json'.length); + if (p === 'package.json') return ''; + const idx = p.lastIndexOf('/'); + return idx === -1 ? '' : p.slice(0, idx); + } + + function parentDirVirtual(dir: string): string { + const idx = dir.lastIndexOf('/'); + return idx === -1 ? '' : dir.slice(0, idx); + } + + function resolveDepVirtual( + depName: string, + fromVirtual: string + ): string | undefined { + let cur = dirOfVirtual(fromVirtual); + while (true) { + const prefix = cur ? cur + '/' : ''; + const candidate = `${prefix}node_modules/${depName}/package.json`; + if (packageFilesVirtual.has(candidate)) return candidate; + if (cur === '') break; + cur = parentDirVirtual(cur); + } + return undefined; + } + + for (const depName of Object.keys(allDeps)) { + const resolvedVirtual = resolveDepVirtual(depName, packagePathVirtual); + if (!resolvedVirtual) continue; + if (visited.has(resolvedVirtual)) continue; + visited.add(resolvedVirtual); + + const raw = virtualToRaw.get(resolvedVirtual) ?? resolvedVirtual; + await traverse( + raw, + resolvedVirtual, + depPkg.name, + depth + 1, + pathInTree + ' > ' + depName + ); + } + } + + // Start traversal from root using '/package.json' and a normalized virtual 'package.json' + await traverse('/package.json', 'package.json', undefined, 0, 'root'); + + // Detect duplicates from collected nodes + const duplicateDependencies = detectDuplicates(dependencyNodes); + + 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/dependencies_old.ts b/src/analyze/dependencies_old.ts new file mode 100644 index 0000000..47bcf63 --- /dev/null +++ b/src/analyze/dependencies_old.ts @@ -0,0 +1,368 @@ +import colors from 'picocolors'; +import {analyzePackageModuleType} from '../compute-type.js'; +import type { + PackageJsonLike, + ReportPluginResult, + Message, + Stats +} from '../types.js'; +import {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[] { + console.log(`Starting duplicate detection for ${dependencyNodes.length} nodes`); + 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); + } + } + } + + console.log(`Found ${duplicates.length} duplicate packages`); + 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; +} + +/** + * 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; + } +} + +// Keep the existing tarball analysis for backward compatibility +export async function runDependencyAnalysis( + fileSystem: FileSystem +): Promise { + console.log('Starting dependency analysis'); + const packageFiles = await fileSystem.listPackageFiles(); + console.log(`Found ${packageFiles.length} package.json files`); + const rootDir = await fileSystem.getRootDir(); + const messages: Message[] = []; + + // Find root package.json + const pkg = await parsePackageJson(fileSystem, '/package.json'); + + if (!pkg) { + throw new Error('No package.json found.'); + } + + const installSize = await fileSystem.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, + duplicate: 0 + } + }; + + let cjsDependencies = 0; + let esmDependencies = 0; + const dependencyNodes: DependencyNode[] = []; + const visited = new Set(); + + console.log('Starting dependency tree traversal'); + // Recursively traverse dependencies + async function traverse( + packagePath: string, + parent: string | undefined, + depth: number, + pathInTree: string + ) { + // Защита от циклов + if (visited.has(packagePath)) { + console.log(`CYCLE DETECTED: ${packagePath} already visited`); + console.log(` Current path: ${pathInTree}`); + return; + } + visited.add(packagePath); + + const depPkg = await parsePackageJson(fileSystem, packagePath); + if (!depPkg || !depPkg.name) return; + + console.log(`Processing ${depPkg.name}@${depPkg.version || 'unknown'} at depth ${depth}`); + console.log(` Package path: ${packagePath}`); + console.log(` Tree path: ${pathInTree}`); + + // 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); + if (type === 'cjs') cjsDependencies++; + if (type === 'esm') esmDependencies++; + if (type === 'dual') { + cjsDependencies++; + esmDependencies++; + } + } + + // Traverse dependencies + const allDeps = {...depPkg.dependencies, ...depPkg.devDependencies}; + console.log(` Dependencies to process: ${Object.keys(allDeps).length}`); + + for (const depName of Object.keys(allDeps)) { + console.log(` Looking for dependency: ${depName}`); + + let packageMatch = packageFiles.find((packageFile) => + normalizePath(packageFile).endsWith( + `/node_modules/${depName}/package.json` + ) + ); + + if (!packageMatch) { + // console.log(` Fallback search for: ${depName}`); + for (const packageFile of packageFiles) { + const depPkg = await parsePackageJson(fileSystem, packageFile); + if (depPkg !== null && depPkg.name === depName) { + packageMatch = packageFile; + // console.log(` Found via name match: ${packageFile}`); + break; + } + } + } else { + console.log(` Found via path match: ${packageMatch}`); + } + + if (packageMatch) { + console.log(` Traversing into: ${depName} -> ${packageMatch}`); + await traverse( + packageMatch, + depPkg.name, + depth + 1, + pathInTree + ' > ' + depName + ); + } else { + console.log(` NOT FOUND: ${depName}`); + } + } + } + + // Start traversal from root + await traverse('/package.json', undefined, 0, 'root'); + console.log(`Tree traversal completed, found ${dependencyNodes.length} nodes`); + console.log(`Visited ${visited.size} unique package paths`); + + console.log('Starting additional file scan for missed packages'); + let additionalPackages = 0; + // 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(fileSystem, 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) { + additionalPackages++; + // 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 + } + } + console.log(`Additional scan found ${additionalPackages} missed packages`); + + // Detect duplicates from the collected dependency nodes + const duplicateDependencies = detectDuplicates(dependencyNodes); + + 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 + }); + } + } + + console.log('Dependency analysis completed'); + return {stats, messages}; +} diff --git a/src/analyze/dependencies_one_more_time.ts b/src/analyze/dependencies_one_more_time.ts new file mode 100644 index 0000000..eaceddf --- /dev/null +++ b/src/analyze/dependencies_one_more_time.ts @@ -0,0 +1,279 @@ +import colors from 'picocolors'; +import {analyzePackageModuleType} from '../compute-type.js'; +import type { + PackageJsonLike, + ReportPluginResult, + Message, + Stats +} from '../types.js'; +import {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; +} + +/** + * 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; + } +} + +// Keep the existing tarball analysis for backward compatibility +export async function runDependencyAnalysis( + fileSystem: FileSystem +): Promise { + const packageFiles = await fileSystem.listPackageFiles(); + const rootDir = await fileSystem.getRootDir(); + const messages: Message[] = []; + + // Find root package.json + const pkg = await parsePackageJson(fileSystem, '/package.json'); + + if (!pkg) { + throw new Error('No package.json found.'); + } + + const installSize = await fileSystem.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, + duplicate: 0 + } + }; + + let cjsDependencies = 0; + let esmDependencies = 0; + const dependencyNodes: DependencyNode[] = []; + + // Recursively traverse dependencies + const rootPackageJsonPath = normalizePath(rootDir) + '/package.json'; + + for (const file of packageFiles) { + const normalizedFile = normalizePath(file); + + // Skip root package files + if (normalizedFile === rootPackageJsonPath || normalizedFile === '/package.json') { + continue; + } + + const depPkg = await parsePackageJson(fileSystem, file); + if (!depPkg || !depPkg.name) { + continue; + } + + const parts = normalizedFile.split('/node_modules/'); + const packages: string[] = []; + + if (parts.length > 1) { + for (let i = 1; i < parts.length; i++) { + const remainder = parts[i]; + const segParts = remainder.split('/'); + if (segParts[0].startsWith('@') && segParts.length >= 2) { + packages.push(segParts[0] + '/' + segParts[1]); + } else { + packages.push(segParts[0]); + } + } + } + + const depth = packages.length; + const pathInTree = + depth === 0 ? 'root' : 'root' + ' > ' + packages.join(' > '); + const parent = + depth === 0 + ? undefined + : depth === 1 + ? pkg.name || 'root' + : packages[packages.length - 2]; + + dependencyNodes.push({ + name: depPkg.name, + version: depPkg.version || 'unknown', + path: pathInTree, + parent, + depth, + packagePath: file + }); + + if (depth > 0) { + const type = analyzePackageModuleType(depPkg); + if (type === 'cjs') cjsDependencies++; + if (type === 'esm') esmDependencies++; + if (type === 'dual') { + cjsDependencies++; + esmDependencies++; + } + } + } + + // Detect duplicates from the collected dependency nodes + const duplicateDependencies = detectDuplicates(dependencyNodes); + + 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}; +}