Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions server/utils/dependency-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -342,15 +357,7 @@ export const analyzeDependencyTree = defineCachedFunction(
)
}

return {
package: name,
version,
vulnerablePackages,
deprecatedPackages,
totalPackages: packages.length,
failedQueries,
totalCounts,
}
return result
},
{
maxAge: 60 * 60,
Expand Down
51 changes: 43 additions & 8 deletions server/utils/dependency-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand All @@ -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<string, { range: string; optional: boolean; path: string[] }>([
[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<string, { range: string; optional: boolean; path: string[] }>()
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()) {
Expand All @@ -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

Expand All @@ -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,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (options.trackDepth) {
pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive'
pkg.path = currentPath
Expand All @@ -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,
})
}
}
}
Expand All @@ -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,
})
}
}
}
Expand Down
8 changes: 6 additions & 2 deletions server/utils/install-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions shared/types/install-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions test/unit/server/utils/dependency-resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
})
54 changes: 54 additions & 0 deletions test/unit/server/utils/install-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'

vi.stubGlobal(
'defineCachedFunction',
<TArgs extends unknown[], TResult>(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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 }),
]),
)
})
})
Loading