diff --git a/src/analyze/attw.ts b/src/analyze/attw.ts index 97cf762..adccb7e 100644 --- a/src/analyze/attw.ts +++ b/src/analyze/attw.ts @@ -5,37 +5,41 @@ import { } from '@arethetypeswrong/core'; import {groupProblemsByKind} from '@arethetypeswrong/core/utils'; import {filterProblems, problemKindInfo} from '@arethetypeswrong/core/problems'; -import {Message} from '../types.js'; +import {ReportPluginResult} from '../types.js'; import type {FileSystem} from '../file-system.js'; import {TarballFileSystem} from '../tarball-file-system.js'; -export async function runAttw(fileSystem: FileSystem) { - const messages: Message[] = []; +export async function runAttw( + fileSystem: FileSystem +): Promise { + const result: ReportPluginResult = { + messages: [] + }; // Only support tarballs for now if (!(fileSystem instanceof TarballFileSystem)) { - return messages; + return result; } const pkg = createPackageFromTarballData(new Uint8Array(fileSystem.tarball)); - const result = await checkPackage(pkg); + const attwResult = await checkPackage(pkg); - if (result.types === false) { - messages.push({ + if (attwResult.types === false) { + result.messages.push({ severity: 'suggestion', score: 0, message: `No type definitions found.` }); } else { - const subpaths = Object.keys(result.entrypoints); + const subpaths = Object.keys(attwResult.entrypoints); for (const subpath of subpaths) { - const resolutions = result.entrypoints[subpath].resolutions; + const resolutions = attwResult.entrypoints[subpath].resolutions; for (const resolutionKind in resolutions) { const problemsForMatrix = Object.entries( groupProblemsByKind( - filterProblems(result, { + filterProblems(attwResult, { resolutionKind: resolutionKind as ResolutionKind, entrypoint: subpath }) @@ -43,7 +47,7 @@ export async function runAttw(fileSystem: FileSystem) { ); for (const [_kind, problems] of problemsForMatrix) { for (const problem of problems) { - messages.push({ + result.messages.push({ severity: 'error', score: 0, message: @@ -56,5 +60,5 @@ export async function runAttw(fileSystem: FileSystem) { } } - return messages; + return result; } diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index d957c47..7c26e4f 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -1,19 +1,30 @@ +import colors from 'picocolors'; import {analyzePackageModuleType} from '../compute-type.js'; import type { - DependencyStats, - DependencyAnalyzer, PackageJsonLike, - DependencyNode, - DuplicateDependency + ReportPluginResult, + Message, + Stat } from '../types.js'; import {FileSystem} from '../file-system.js'; -/** - * This file contains dependency analysis functionality. - */ +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 +} -// Re-export types -export type {DependencyStats, DependencyAnalyzer}; +interface DuplicateDependency { + name: string; + versions: DependencyNode[]; + severity: 'exact' | 'conflict' | 'resolvable'; + potentialSavings?: number; + suggestions?: string[]; +} /** * Detects duplicate dependencies from a list of dependency nodes @@ -134,11 +145,12 @@ async function parsePackageJson( } // Keep the existing tarball analysis for backward compatibility -export async function analyzeDependencies( +export async function runDependencyAnalysis( fileSystem: FileSystem -): Promise { +): 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'); @@ -148,8 +160,35 @@ export async function analyzeDependencies( } const installSize = await fileSystem.getInstallSize(); - const directDependencies = Object.keys(pkg.dependencies || {}).length; + const prodDependencies = Object.keys(pkg.dependencies || {}).length; const devDependencies = Object.keys(pkg.devDependencies || {}).length; + const stats: Stat[] = [ + { + name: 'packageName', + label: 'Package Name', + value: pkg.name + }, + { + name: 'version', + label: 'Version', + value: pkg.version + }, + { + name: 'installSize', + label: 'Install Size', + value: installSize + }, + { + name: 'prodDependencies', + label: 'Prod. Dependencies', + value: prodDependencies + }, + { + name: 'devDependencies', + label: 'Dev. Dependencies', + value: devDependencies + } + ]; let cjsDependencies = 0; let esmDependencies = 0; @@ -268,20 +307,48 @@ export async function analyzeDependencies( // Detect duplicates from the collected dependency nodes const duplicateDependencies = detectDuplicates(dependencyNodes); - const result: DependencyStats = { - totalDependencies: directDependencies + devDependencies, - directDependencies, - devDependencies, - cjsDependencies, - esmDependencies, - installSize, - packageName: pkg.name, - version: pkg.version - }; + stats.push({ + name: 'cjsDependencies', + label: 'CJS Dependencies', + value: cjsDependencies + }); + stats.push({ + name: 'esmDependencies', + label: 'ESM Dependencies', + value: esmDependencies + }); if (duplicateDependencies.length > 0) { - result.duplicateDependencies = duplicateDependencies; + stats.push({ + name: 'duplicateDependencies', + label: 'Duplicate Dependencies', + value: 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 result; + return {stats, messages}; } diff --git a/src/analyze/publint.ts b/src/analyze/publint.ts index 23a8583..ad80798 100644 --- a/src/analyze/publint.ts +++ b/src/analyze/publint.ts @@ -1,24 +1,28 @@ import {publint} from 'publint'; import {formatMessage} from 'publint/utils'; -import {Message} from '../types.js'; +import {ReportPluginResult} from '../types.js'; import type {FileSystem} from '../file-system.js'; import {TarballFileSystem} from '../tarball-file-system.js'; -export async function runPublint(fileSystem: FileSystem) { - const messages: Message[] = []; +export async function runPublint( + fileSystem: FileSystem +): Promise { + const result: ReportPluginResult = { + messages: [] + }; if (!(fileSystem instanceof TarballFileSystem)) { - return messages; + return result; } - const result = await publint({pack: {tarball: fileSystem.tarball}}); - for (const problem of result.messages) { - messages.push({ + const publintResult = await publint({pack: {tarball: fileSystem.tarball}}); + for (const problem of publintResult.messages) { + result.messages.push({ severity: problem.type, score: 0, - message: formatMessage(problem, result.pkg) ?? '' + message: formatMessage(problem, publintResult.pkg) ?? '' }); } - return messages; + return result; } diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index ef6dd75..338d652 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -1,5 +1,5 @@ import * as replacements from 'module-replacements'; -import {Message, PackageJsonLike} from '../types.js'; +import {PackageJsonLike, ReportPluginResult} from '../types.js'; import type {FileSystem} from '../file-system.js'; /** @@ -20,8 +20,12 @@ export function getMdnUrl(path: string): string { return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${path}`; } -export async function runReplacements(fileSystem: FileSystem) { - const messages: Message[] = []; +export async function runReplacements( + fileSystem: FileSystem +): Promise { + const result: ReportPluginResult = { + messages: [] + }; let packageJsonText: string; @@ -29,7 +33,7 @@ export async function runReplacements(fileSystem: FileSystem) { packageJsonText = await fileSystem.readFile('/package.json'); } catch { // No package.json found - return messages; + return result; } let packageJson: PackageJsonLike; @@ -38,12 +42,12 @@ export async function runReplacements(fileSystem: FileSystem) { packageJson = JSON.parse(packageJsonText); } catch { // Not parseable - return messages; + return result; } if (!packageJson.dependencies) { // No dependencies - return messages; + return result; } for (const name of Object.keys(packageJson.dependencies)) { @@ -56,13 +60,13 @@ export async function runReplacements(fileSystem: FileSystem) { } if (replacement.type === 'none') { - messages.push({ + result.messages.push({ severity: 'warning', score: 0, message: `Module "${name}" can be removed, and native functionality used instead` }); } else if (replacement.type === 'simple') { - messages.push({ + result.messages.push({ severity: 'warning', score: 0, message: `Module "${name}" can be replaced. ${replacement.replacement}.` @@ -71,14 +75,14 @@ export async function runReplacements(fileSystem: FileSystem) { const mdnPath = getMdnUrl(replacement.mdnPath); // TODO (43081j): support `nodeVersion` here, check it against the // packageJson.engines field, if there is one. - messages.push({ + result.messages.push({ severity: 'warning', score: 0, message: `Module "${name}" can be replaced with native functionality. Use "${replacement.replacement}" instead. You can read more at ${mdnPath}.` }); } else if (replacement.type === 'documented') { const docUrl = getDocsUrl(replacement.docPath); - messages.push({ + result.messages.push({ severity: 'warning', score: 0, message: `Module "${name}" can be replaced with a more performant alternative. See the list of available alternatives at ${docUrl}.` @@ -86,5 +90,5 @@ export async function runReplacements(fileSystem: FileSystem) { } } - return messages; + return result; } diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 29bc411..b8cc052 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -1,27 +1,20 @@ import {detectAndPack} from '#detect-and-pack'; -import {analyzePackageModuleType, PackageModuleType} from '../compute-type.js'; -import {analyzeDependencies, type DependencyStats} from './dependencies.js'; +import {analyzePackageModuleType} from '../compute-type.js'; import {LocalFileSystem} from '../local-file-system.js'; import {TarballFileSystem} from '../tarball-file-system.js'; import type {FileSystem} from '../file-system.js'; -import {Message, Options} from '../types.js'; +import {Message, Options, ReportPlugin, Stat} from '../types.js'; import {runAttw} from './attw.js'; import {runPublint} from './publint.js'; import {runReplacements} from './replacements.js'; +import {runDependencyAnalysis} from './dependencies.js'; -export type ReportPlugin = (fileSystem: FileSystem) => Promise; - -export interface ReportResult { - info: { - name: string; - version: string; - type: PackageModuleType; - }; - messages: Message[]; - dependencies: DependencyStats; -} - -const plugins: ReportPlugin[] = [runAttw, runPublint, runReplacements]; +const plugins: ReportPlugin[] = [ + runAttw, + runPublint, + runReplacements, + runDependencyAnalysis +]; async function computeInfo(fileSystem: FileSystem) { try { @@ -42,6 +35,8 @@ export async function report(options: Options) { let fileSystem: FileSystem; const messages: Message[] = []; + const stats: Stat[] = []; + const seenStatKeys = new Set(); if (pack === 'none') { fileSystem = new LocalFileSystem(root); @@ -60,13 +55,22 @@ export async function report(options: Options) { for (const plugin of plugins) { const result = await plugin(fileSystem); - for (const message of result) { + for (const message of result.messages) { messages.push(message); } + + if (result.stats) { + for (const stat of result.stats) { + if (seenStatKeys.has(stat.name)) { + continue; + } + seenStatKeys.add(stat.name); + stats.push(stat); + } + } } const info = await computeInfo(fileSystem); - const dependencies = await analyzeDependencies(fileSystem); - return {info, messages, dependencies}; + return {info, messages, stats}; } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 4763883..65dbdba 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -5,7 +5,7 @@ import c from 'picocolors'; import {meta} from './analyze.meta.js'; import {report} from '../index.js'; import {logger} from '../cli.js'; -import type {PackType} from '../types.js'; +import type {PackType, Stat} from '../types.js'; const allowedPackTypes: PackType[] = ['auto', 'npm', 'yarn', 'pnpm', 'bun']; @@ -22,6 +22,13 @@ function formatBytes(bytes: number) { return `${size.toFixed(1)} ${units[unitIndex]}`; } +function formatStat(stat: Stat): string { + if (stat.name === 'installSize' && typeof stat.value === 'number') { + return formatBytes(stat.value); + } + return stat.value.toString(); +} + export async function run(ctx: CommandContext) { const root = ctx.positionals[1]; let pack: PackType = ctx.values.pack; @@ -62,41 +69,28 @@ export async function run(ctx: CommandContext) { } // Then analyze the tarball - const {dependencies, messages} = await report({root, pack}); + const {stats, messages} = await report({root, pack}); prompts.log.info('Summary'); - prompts.log.message( - `${c.cyan('Total deps ')} ${dependencies.totalDependencies}`, - {spacing: 0} - ); - prompts.log.message( - `${c.cyan('Direct deps ')} ${dependencies.directDependencies}`, - {spacing: 0} - ); - prompts.log.message( - `${c.cyan('Dev deps ')} ${dependencies.devDependencies}`, - {spacing: 0} - ); - prompts.log.message( - `${c.cyan('CJS deps ')} ${dependencies.cjsDependencies}`, - {spacing: 0} - ); - prompts.log.message( - `${c.cyan('ESM deps ')} ${dependencies.esmDependencies}`, - {spacing: 0} - ); - prompts.log.message( - `${c.cyan('Install size ')} ${formatBytes(dependencies.installSize)}`, - {spacing: 0} - ); - - // Display duplicate dependency information - if ( - dependencies.duplicateDependencies && - dependencies.duplicateDependencies.length > 0 - ) { + + let longestStatName = 0; + + // Iterate once to find the longest stat name + for (const stat of stats) { + const statName = stat.label ?? stat.name; + if (statName.length > longestStatName) { + longestStatName = statName.length; + } + } + + // Iterate again (unfortunately) to display the stats + for (const stat of stats) { + const statName = stat.label ?? stat.name; + const statValueString = formatStat(stat); + const paddingSize = + longestStatName - statName.length + statValueString.length + 2; prompts.log.message( - `${c.yellow('Duplicates ')} ${dependencies.duplicateDependencies.length}`, + `${c.cyan(`${statName}`)}${statValueString.padStart(paddingSize)}`, {spacing: 0} ); } @@ -104,46 +98,6 @@ export async function run(ctx: CommandContext) { prompts.log.info('Results:'); prompts.log.message('', {spacing: 0}); - // Display duplicate dependencies or a message if none found - if ( - dependencies.duplicateDependencies && - dependencies.duplicateDependencies.length > 0 - ) { - prompts.log.message(c.yellow('Duplicate Dependencies:'), {spacing: 0}); - for (const duplicate of dependencies.duplicateDependencies) { - const severityColor = duplicate.severity === 'exact' ? c.blue : c.yellow; - - prompts.log.message( - ` ${severityColor('•')} ${c.bold(duplicate.name)} (${duplicate.versions.length} versions)`, - {spacing: 0} - ); - - // Show version details - for (const version of duplicate.versions) { - prompts.log.message( - ` ${c.gray(version.version)} via ${c.gray(version.path)}`, - {spacing: 0} - ); - } - - // Show suggestions - if (duplicate.suggestions && duplicate.suggestions.length > 0) { - for (const suggestion of duplicate.suggestions) { - prompts.log.message(` ${c.blue('💡')} ${c.gray(suggestion)}`, { - spacing: 0 - }); - } - } - - prompts.log.message('', {spacing: 0}); - } - } else { - prompts.log.message(c.green('✅ No duplicated dependencies found.'), { - spacing: 0 - }); - prompts.log.message('', {spacing: 0}); - } - // Display tool analysis results if (messages.length > 0) { const errorMessages = messages.filter((m) => m.severity === 'error'); diff --git a/src/index.ts b/src/index.ts index 84fa75f..c5fa6fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -import type {Message, Options, DependencyStats} from './types.js'; +import type {Message, Options, Stat} from './types.js'; import type {PackageModuleType} from './compute-type.js'; -export type {Message, Options, PackageModuleType, DependencyStats}; +export type {Message, Options, PackageModuleType, Stat}; export {report} from './analyze/report.js'; diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index 0f67667..d76a3c3 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -6,17 +6,16 @@ exports[`CLI > should display package report 1`] = ` ┌ Analyzing... │ ● Summary -│ Total deps  1 -│ Direct deps  1 -│ Dev deps  0 -│ CJS deps  0 -│ ESM deps  0 -│ Install size  170.0 B +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 170.0 B +│ Prod. Dependencies 1 +│ Dev. Dependencies 0 +│ CJS Dependencies 0 +│ ESM Dependencies 0 │ ● Results: │ -│ ✅ No duplicated dependencies found. -│ │ Suggestions: │ • No type definitions found. │ @@ -34,17 +33,16 @@ exports[`CLI > should run successfully with default options 1`] = ` ┌ Analyzing... │ ● Summary -│ Total deps  1 -│ Direct deps  1 -│ Dev deps  0 -│ CJS deps  0 -│ ESM deps  0 -│ Install size  170.0 B +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 170.0 B +│ Prod. Dependencies 1 +│ Dev. Dependencies 0 +│ CJS Dependencies 0 +│ ESM Dependencies 0 │ ● Results: │ -│ ✅ No duplicated dependencies found. -│ │ Suggestions: │ • No type definitions found. │ diff --git a/src/test/__snapshots__/duplicate-dependencies.test.ts.snap b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap new file mode 100644 index 0000000..eb65781 --- /dev/null +++ b/src/test/__snapshots__/duplicate-dependencies.test.ts.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Duplicate Dependency Detection > should detect exact duplicate dependencies 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": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 244, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 2, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 4, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + { + "label": "Duplicate Dependencies", + "name": "duplicateDependencies", + "value": 1, + }, + ], +} +`; + +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": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 292, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 2, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 4, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + { + "label": "Duplicate Dependencies", + "name": "duplicateDependencies", + "value": 1, + }, + ], +} +`; + +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": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 244, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 2, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 4, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + { + "label": "Duplicate Dependencies", + "name": "duplicateDependencies", + "value": 1, + }, + ], +} +`; + +exports[`Duplicate Dependency Detection > should not detect duplicates when there are none 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 47, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 1, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 1, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + ], +} +`; diff --git a/src/test/analyze/__snapshots__/dependencies.test.ts.snap b/src/test/analyze/__snapshots__/dependencies.test.ts.snap new file mode 100644 index 0000000..9ecdd4a --- /dev/null +++ b/src/test/analyze/__snapshots__/dependencies.test.ts.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`analyzeDependencies (local) > should analyze dependencies correctly 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 299, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 2, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 1, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 2, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 1, + }, + ], +} +`; + +exports[`analyzeDependencies (local) > should handle empty project 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 0, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 0, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 0, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + ], +} +`; + +exports[`analyzeDependencies (local) > should handle missing node_modules 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 0, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 1, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 0, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + ], +} +`; + +exports[`analyzeDependencies (local) > should handle symlinks 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 72, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 1, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 0, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 1, + }, + ], +} +`; + +exports[`analyzeDependencies (tarball) > should analyze a real tarball fixture 1`] = ` +{ + "messages": [], + "stats": [ + { + "label": "Package Name", + "name": "packageName", + "value": "test-package", + }, + { + "label": "Version", + "name": "version", + "value": "1.0.0", + }, + { + "label": "Install Size", + "name": "installSize", + "value": 226, + }, + { + "label": "Prod. Dependencies", + "name": "prodDependencies", + "value": 0, + }, + { + "label": "Dev. Dependencies", + "name": "devDependencies", + "value": 0, + }, + { + "label": "CJS Dependencies", + "name": "cjsDependencies", + "value": 0, + }, + { + "label": "ESM Dependencies", + "name": "esmDependencies", + "value": 0, + }, + ], +} +`; diff --git a/src/test/analyze/dependencies.ts b/src/test/analyze/dependencies.test.ts similarity index 56% rename from src/test/analyze/dependencies.ts rename to src/test/analyze/dependencies.test.ts index 6153b88..ee67257 100644 --- a/src/test/analyze/dependencies.ts +++ b/src/test/analyze/dependencies.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; -import {analyzeDependencies} from '../../analyze/dependencies.js'; +import {runDependencyAnalysis} from '../../analyze/dependencies.js'; import {TarballFileSystem} from '../../tarball-file-system.js'; import {LocalFileSystem} from '../../local-file-system.js'; import { @@ -11,26 +11,22 @@ import { } from '../utils.js'; import fs from 'node:fs/promises'; import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const FIXTURE_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../../test/fixtures' +); // Integration test using a real tarball fixture describe('analyzeDependencies (tarball)', () => { it('should analyze a real tarball fixture', async () => { - const tarballPath = path.join(__dirname, 'fixtures', 'test-package.tgz'); + const tarballPath = path.join(FIXTURE_DIR, 'test-package.tgz'); const tarballBuffer = await fs.readFile(tarballPath); const fileSystem = new TarballFileSystem(tarballBuffer.buffer); - const result = await analyzeDependencies(fileSystem); - expect(result).toMatchObject({ - totalDependencies: expect.any(Number), - directDependencies: expect.any(Number), - devDependencies: expect.any(Number), - cjsDependencies: expect.any(Number), - esmDependencies: expect.any(Number), - installSize: expect.any(Number), - duplicateDependencies: expect.any(Number), - packageName: 'test-package', - version: '1.0.0' - }); + const result = await runDependencyAnalysis(fileSystem); + expect(result).toMatchSnapshot(); }); }); @@ -53,19 +49,8 @@ describe('analyzeDependencies (local)', () => { version: '1.0.0' }); - const stats = await analyzeDependencies(fileSystem); - expect(stats).toEqual({ - totalDependencies: 0, - directDependencies: 0, - devDependencies: 0, - cjsDependencies: 0, - esmDependencies: 0, - installSize: 0, - packageName: 'test-package', - version: '1.0.0', - duplicateCount: 0 - }); - expect(stats.duplicateDependencies).toBeUndefined(); + const stats = await runDependencyAnalysis(fileSystem); + expect(stats).toMatchSnapshot(); }); it('should analyze dependencies correctly', async () => { @@ -107,19 +92,8 @@ describe('analyzeDependencies (local)', () => { await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - const stats = await analyzeDependencies(fileSystem); - expect(stats).toEqual({ - totalDependencies: 3, - directDependencies: 2, - devDependencies: 1, - cjsDependencies: 2, // cjs-package and dev-package - esmDependencies: 1, // esm-package - installSize: expect.any(Number), - packageName: 'test-package', - version: '1.0.0', - duplicateCount: 0 - }); - expect(stats.duplicateDependencies).toBeUndefined(); + const stats = await runDependencyAnalysis(fileSystem); + expect(stats).toMatchSnapshot(); }); it('should handle symlinks', async () => { @@ -152,19 +126,8 @@ describe('analyzeDependencies (local)', () => { 'dir' ); - const stats = await analyzeDependencies(fileSystem); - expect(stats).toEqual({ - totalDependencies: 1, - directDependencies: 1, - devDependencies: 0, - cjsDependencies: 0, - esmDependencies: 1, - installSize: expect.any(Number), - packageName: 'test-package', - version: '1.0.0', - duplicateCount: 0 - }); - expect(stats.duplicateDependencies).toBeUndefined(); + const stats = await runDependencyAnalysis(fileSystem); + expect(stats).toMatchSnapshot(); }); it('should handle missing node_modules', async () => { @@ -176,18 +139,7 @@ describe('analyzeDependencies (local)', () => { } }); - const stats = await analyzeDependencies(fileSystem); - expect(stats).toEqual({ - totalDependencies: 1, - directDependencies: 1, - devDependencies: 0, - cjsDependencies: 0, - esmDependencies: 0, - installSize: 0, - packageName: 'test-package', - version: '1.0.0', - duplicateCount: 0 - }); - expect(stats.duplicateDependencies).toBeUndefined(); + const stats = await runDependencyAnalysis(fileSystem); + expect(stats).toMatchSnapshot(); }); }); diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index 7411438..f4f1fc4 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; -import {analyzeDependencies} from '../analyze/dependencies.js'; +import {runDependencyAnalysis} from '../analyze/dependencies.js'; import {LocalFileSystem} from '../local-file-system.js'; import { createTempDir, @@ -57,37 +57,9 @@ describe('Duplicate Dependency Detection', () => { await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - const stats = await analyzeDependencies(fileSystem); + const stats = await runDependencyAnalysis(fileSystem); - expect(stats.duplicateDependencies).toEqual([ - { - potentialSavings: 1, - name: 'shared-lib', - severity: 'exact', - 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' - ], - versions: [ - { - name: 'shared-lib', - version: '2.0.0', - path: 'root > package-a > shared-lib', - parent: 'package-a', - depth: 2, - packagePath: expect.any(String) - }, - { - name: 'shared-lib', - version: '2.0.0', - path: 'root > package-b > shared-lib', - parent: 'package-b', - depth: 2, - packagePath: expect.any(String) - } - ] - } - ]); + expect(stats).toMatchSnapshot(); }); it('should detect version conflicts', async () => { @@ -147,45 +119,9 @@ describe('Duplicate Dependency Detection', () => { version: '2.0.0' }); - const stats = await analyzeDependencies(fileSystem); + const stats = await runDependencyAnalysis(fileSystem); - expect(stats.duplicateDependencies).toEqual([ - { - name: 'shared-lib', - potentialSavings: 2, - severity: 'conflict', - 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' - ], - versions: [ - { - name: 'shared-lib', - packagePath: expect.any(String), - depth: 2, - parent: 'package-a', - path: 'root > package-a > shared-lib', - version: '1.0.0' - }, - { - name: 'shared-lib', - packagePath: expect.any(String), - depth: 2, - parent: 'package-b', - path: 'root > package-b > shared-lib', - version: '1.0.0' - }, - { - name: 'shared-lib', - packagePath: expect.any(String), - depth: 2, - parent: 'package-b', - path: 'shared-lib', - version: '2.0.0' - } - ] - } - ]); + expect(stats).toMatchSnapshot(); }); it('should not detect duplicates when there are none', async () => { @@ -206,9 +142,9 @@ describe('Duplicate Dependency Detection', () => { await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - const stats = await analyzeDependencies(fileSystem); + const stats = await runDependencyAnalysis(fileSystem); - expect(stats.duplicateDependencies).toBeUndefined(); + expect(stats).toMatchSnapshot(); }); it('should generate suggestions for duplicates', async () => { @@ -244,36 +180,8 @@ describe('Duplicate Dependency Detection', () => { await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); - const stats = await analyzeDependencies(fileSystem); + const stats = await runDependencyAnalysis(fileSystem); - expect(stats.duplicateDependencies).toEqual([ - { - name: 'shared-lib', - potentialSavings: 1, - severity: 'exact', - 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' - ], - versions: [ - { - depth: 2, - name: 'shared-lib', - packagePath: expect.any(String), - parent: 'package-a', - path: 'root > package-a > shared-lib', - version: '2.0.0' - }, - { - depth: 2, - name: 'shared-lib', - packagePath: expect.any(String), - parent: 'package-b', - path: 'root > package-b > shared-lib', - version: '2.0.0' - } - ] - } - ]); + expect(stats).toMatchSnapshot(); }); }); diff --git a/src/types.ts b/src/types.ts index b3f0a34..618b5fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import {codemods} from 'module-replacements-codemods'; +import type {FileSystem} from './file-system.js'; export interface PackFile { name: string; @@ -19,44 +20,18 @@ export interface Options { pack?: PackType; } -export interface Message { - severity: 'error' | 'warning' | 'suggestion'; - score: number; - message: string; -} - -export 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 -} - -export interface DuplicateDependency { +export interface StatLike { name: string; - versions: DependencyNode[]; - severity: 'exact' | 'conflict' | 'resolvable'; - potentialSavings?: number; - suggestions?: string[]; + label?: string; + value: T; } -export interface DependencyStats { - totalDependencies: number; - directDependencies: number; - devDependencies: number; - cjsDependencies: number; - esmDependencies: number; - installSize: number; - packageName?: string; - version?: string; - duplicateDependencies?: DuplicateDependency[]; -} +export type Stat = StatLike | StatLike; -export interface DependencyAnalyzer { - analyzeDependencies(root?: string): Promise; +export interface Message { + severity: 'error' | 'warning' | 'suggestion'; + score: number; + message: string; } export interface PackageJsonLike { @@ -72,3 +47,12 @@ export interface Replacement { condition?: (filename: string, source: string) => Promise; factory: (typeof codemods)[keyof typeof codemods]; } + +export interface ReportPluginResult { + stats?: Stat[]; + messages: Message[]; +} + +export type ReportPlugin = ( + fileSystem: FileSystem +) => Promise; diff --git a/src/test/fixtures/test-package.tgz b/test/fixtures/test-package.tgz similarity index 100% rename from src/test/fixtures/test-package.tgz rename to test/fixtures/test-package.tgz diff --git a/src/test/fixtures/test-package/package.json b/test/fixtures/test-package/package.json similarity index 100% rename from src/test/fixtures/test-package/package.json rename to test/fixtures/test-package/package.json