diff --git a/server/utils/dependency-analysis.ts b/server/utils/dependency-analysis.ts index 0b42ee636e..e767592968 100644 --- a/server/utils/dependency-analysis.ts +++ b/server/utils/dependency-analysis.ts @@ -325,13 +325,28 @@ export const analyzeDependencyTree = defineCachedFunction( }) // Aggregate total counts - const totalCounts = { total: 0, critical: 0, high: 0, moderate: 0, low: 0 } - for (const pkg of vulnerablePackages) { - totalCounts.total += pkg.counts.total - totalCounts.critical += pkg.counts.critical - totalCounts.high += pkg.counts.high - totalCounts.moderate += pkg.counts.moderate - totalCounts.low += pkg.counts.low + // Count how many native optional packages were included, to exclude them from the total + let nativeOptionalCount = 0 + for (const pkg of resolved.values()) { + if (pkg.optional && pkg.isNative) { + nativeOptionalCount++ + } + } + + const result: VulnerabilityTreeResult = { + package: name, + version, + vulnerablePackages, + deprecatedPackages, + totalCounts: { + total: vulnerablePackages.reduce((sum, pkg) => sum + pkg.counts.total, 0), + critical: vulnerablePackages.reduce((sum, pkg) => sum + pkg.counts.critical, 0), + high: vulnerablePackages.reduce((sum, pkg) => sum + pkg.counts.high, 0), + moderate: vulnerablePackages.reduce((sum, pkg) => sum + pkg.counts.moderate, 0), + low: vulnerablePackages.reduce((sum, pkg) => sum + pkg.counts.low, 0), + }, + totalPackages: resolved.size - nativeOptionalCount, + failedQueries, } // Log if batch query failed entirely @@ -342,15 +357,7 @@ export const analyzeDependencyTree = defineCachedFunction( ) } - return { - package: name, - version, - vulnerablePackages, - deprecatedPackages, - totalPackages: packages.length, - failedQueries, - totalCounts, - } + return result }, { maxAge: 60 * 60, diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index 3b49ce6b49..63d0099c7c 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -100,6 +100,7 @@ export interface ResolvedPackage { size: number tarballUrl: string optional: boolean + isNative?: boolean /** Depth level (only when trackDepth is enabled) */ depth?: DependencyDepth /** Dependency path from root (only when trackDepth is enabled) */ @@ -122,13 +123,17 @@ export async function resolveDependencyTree( // Process level by level for correct depth tracking // Each entry includes the path of package names leading to this dependency - let currentLevel = new Map([ - [rootName, { range: rootVersion, optional: false, path: [] }], - ]) + let currentLevel = new Map< + string, + { range: string; optional: boolean; isNativeParent?: boolean; path: string[] } + >([[rootName, { range: rootVersion, optional: false, isNativeParent: false, path: [] }]]) let level = 0 while (currentLevel.size > 0) { - const nextLevel = new Map() + const nextLevel = new Map< + string, + { range: string; optional: boolean; isNativeParent?: boolean; path: string[] } + >() // Mark all packages in current level as seen before processing for (const name of currentLevel.keys()) { @@ -139,7 +144,7 @@ export async function resolveDependencyTree( const entries = [...currentLevel.entries()] await mapWithConcurrency( entries, - async ([name, { range, optional, path }]) => { + async ([name, { range, optional: parentOptional, isNativeParent, path }]) => { const packument = await fetchPackument(name) if (!packument) return @@ -156,11 +161,30 @@ export async function resolveDependencyTree( const tarballUrl = versionData.dist?.tarball ?? '' const key = `${name}@${version}` + const libc = (versionData as { libc?: unknown }).libc + const selfIsNative = !!( + (versionData.os && Array.isArray(versionData.os) && versionData.os.length > 0) || + (versionData.cpu && Array.isArray(versionData.cpu) && versionData.cpu.length > 0) || + (Array.isArray(libc) && libc.length > 0) || + name.includes('-wasm32-') || + name.endsWith('-wasm32') || + name.includes('wasm32-wasi') + ) + const isNative = selfIsNative || isNativeParent + const optional = parentOptional + // Build path for this package (path to parent + this package with version) const currentPath = [...path, `${name}@${version}`] if (!resolved.has(key)) { - const pkg: ResolvedPackage = { name, version, size, tarballUrl, optional } + const pkg: ResolvedPackage = { + name, + version, + size, + tarballUrl, + optional, + isNative: isNative || undefined, + } if (options.trackDepth) { pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive' pkg.path = currentPath @@ -175,7 +199,13 @@ export async function resolveDependencyTree( if (versionData.dependencies) { for (const [depName, depRange] of Object.entries(versionData.dependencies)) { if (!seen.has(depName) && !nextLevel.has(depName)) { - nextLevel.set(depName, { range: depRange, optional: false, path: currentPath }) + // Inherit optional and isNative flags from parent if they are truthy + nextLevel.set(depName, { + range: depRange, + optional: optional, + isNativeParent: isNative, + path: currentPath, + }) } } } @@ -184,7 +214,12 @@ export async function resolveDependencyTree( if (versionData.optionalDependencies) { for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) { if (!seen.has(depName) && !nextLevel.has(depName)) { - nextLevel.set(depName, { range: depRange, optional: true, path: currentPath }) + nextLevel.set(depName, { + range: depRange, + optional: true, + isNativeParent: isNative, + path: currentPath, + }) } } } diff --git a/server/utils/install-size.ts b/server/utils/install-size.ts index 431cbd9bbc..eafdaf4bb1 100644 --- a/server/utils/install-size.ts +++ b/server/utils/install-size.ts @@ -29,9 +29,13 @@ export const calculateInstallSize = defineCachedFunction( size: dep.size, tarballUrl: dep.tarballUrl, optional: dep.optional || undefined, + isNative: dep.isNative || undefined, }) - totalSize += dep.size - dependencyCount++ + // Do not count optional native packages as actual dependencies + if (!(dep.optional && dep.isNative)) { + totalSize += dep.size + dependencyCount++ + } } // Sort by size descending diff --git a/shared/types/install-size.ts b/shared/types/install-size.ts index 6591e7c223..09a7fdecac 100644 --- a/shared/types/install-size.ts +++ b/shared/types/install-size.ts @@ -5,6 +5,8 @@ export interface DependencySize { tarballUrl: string /** True if this is an optional dependency */ optional?: boolean + /** True if this is a native package (os/cpu specific) */ + isNative?: boolean } export interface InstallSizeResult { diff --git a/test/unit/server/utils/dependency-resolver.spec.ts b/test/unit/server/utils/dependency-resolver.spec.ts index f1beaaaf2d..6f4d67762c 100644 --- a/test/unit/server/utils/dependency-resolver.spec.ts +++ b/test/unit/server/utils/dependency-resolver.spec.ts @@ -415,5 +415,32 @@ describe('dependency-resolver', () => { expect(result.size).toBe(4) expect(result.has('shared@1.0.0')).toBe(true) }) + + it('correctly propagates isNative flag for native packages and their transitive children', async () => { + mockFetchNpmPackage.mockImplementation(async (name: string) => { + if (name === 'root') + return makePackument('root', [ + { version: '1.0.0', optionalDeps: { 'native-wasm32-wasi': '^1.0.0' } }, + ]) + if (name === 'native-wasm32-wasi') + return makePackument('native-wasm32-wasi', [ + { version: '1.0.0', deps: { 'child-pkg': '^1.0.0' } }, + ]) + if (name === 'child-pkg') return makePackument('child-pkg', [{ version: '1.0.0' }]) + return null + }) + + const result = await resolveDependencyTree('root', '1.0.0') + + expect(result.get('root@1.0.0')!.isNative).toBeUndefined() + + const nativePkg = result.get('native-wasm32-wasi@1.0.0')! + expect(nativePkg.optional).toBe(true) + expect(nativePkg.isNative).toBe(true) + + const childPkg = result.get('child-pkg@1.0.0')! + expect(childPkg.optional).toBe(true) + expect(childPkg.isNative).toBe(true) // Should inherit isNative from parent + }) }) }) diff --git a/test/unit/server/utils/install-size.spec.ts b/test/unit/server/utils/install-size.spec.ts new file mode 100644 index 0000000000..2d8d42f13d --- /dev/null +++ b/test/unit/server/utils/install-size.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.stubGlobal( + 'defineCachedFunction', + (fn: (...args: TArgs) => TResult) => fn, +) + +const mockResolveDependencyTree = vi.fn() +vi.stubGlobal('resolveDependencyTree', mockResolveDependencyTree) + +const { calculateInstallSize } = await import('#server/utils/install-size') + +describe('install-size', () => { + it('excludes native optional dependencies from total size and dependency count', async () => { + mockResolveDependencyTree.mockResolvedValue( + new Map([ + ['root@1.0.0', { name: 'root', version: '1.0.0', size: 100 }], + ['normal-dep@1.0.0', { name: 'normal-dep', version: '1.0.0', size: 50 }], + [ + 'native-dep@1.0.0', + { name: 'native-dep', version: '1.0.0', size: 1000, optional: true, isNative: true }, + ], + [ + 'native-child-dep@1.0.0', + { + name: 'native-child-dep', + version: '1.0.0', + size: 2000, + optional: true, + isNative: true, + }, + ], + ]), + ) + + const result = await calculateInstallSize('root', '1.0.0') + + // Normal dep + root self size = 50 + 100 = 150 + // The native deps (3000 total) should be excluded + expect(result.totalSize).toBe(150) + + // Only 1 normal dependency should be counted + expect(result.dependencyCount).toBe(1) + + // All dependencies should still be returned in the list + expect(result.dependencies).toHaveLength(3) + expect(result.dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'native-dep', optional: true, isNative: true }), + expect.objectContaining({ name: 'native-child-dep', optional: true, isNative: true }), + ]), + ) + }) +})