-
-
Notifications
You must be signed in to change notification settings - Fork 425
Expand file tree
/
Copy pathdependency-resolver.ts
More file actions
201 lines (174 loc) · 6.33 KB
/
dependency-resolver.ts
File metadata and controls
201 lines (174 loc) · 6.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import type { Packument, PackumentVersion, DependencyDepth } from '#shared/types'
import { mapWithConcurrency } from '#shared/utils/async'
import { maxSatisfying } from 'semver'
/** Concurrency limit for fetching packuments during dependency resolution */
const PACKUMENT_FETCH_CONCURRENCY = 20
/**
* Target platform for dependency resolution.
* We resolve for linux-x64 with glibc as a representative platform.
*/
export const TARGET_PLATFORM = {
os: 'linux',
cpu: 'x64',
libc: 'glibc',
}
/**
* Fetch packument with caching (returns null on error for tree traversal).
* Delegates to fetchNpmPackage() to share a single cache for all packument fetches.
*/
async function fetchPackument(name: string): Promise<Packument | null> {
try {
return await fetchNpmPackage(name)
} catch (error) {
if (import.meta.dev) {
// oxlint-disable-next-line no-console -- log npm registry failures for debugging
console.warn(`[dep-resolver] Failed to fetch packument for ${name}:`, error)
}
return null
}
}
/**
* Check if a package version matches the target platform.
* Returns false if the package explicitly excludes our target platform.
*/
export function matchesPlatform(version: PackumentVersion): boolean {
if (version.os && Array.isArray(version.os) && version.os.length > 0) {
const osMatch = version.os.some(os => {
if (os.startsWith('!')) return os.slice(1) !== TARGET_PLATFORM.os
return os === TARGET_PLATFORM.os
})
if (!osMatch) return false
}
if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) {
const cpuMatch = version.cpu.some(cpu => {
if (cpu.startsWith('!')) return cpu.slice(1) !== TARGET_PLATFORM.cpu
return cpu === TARGET_PLATFORM.cpu
})
if (!cpuMatch) return false
}
const libc = (version as { libc?: string[] }).libc
if (libc && Array.isArray(libc) && libc.length > 0) {
const libcMatch = libc.some(l => {
if (l.startsWith('!')) return l.slice(1) !== TARGET_PLATFORM.libc
return l === TARGET_PLATFORM.libc
})
if (!libcMatch) return false
}
return true
}
/**
* Resolve a semver range to a specific version from available versions.
*/
export function resolveVersion(range: string, versions: string[]): string | null {
if (versions.includes(range)) return range
// Handle npm: protocol (aliases)
if (range.startsWith('npm:')) {
const atIndex = range.lastIndexOf('@')
if (atIndex > 4) {
return resolveVersion(range.slice(atIndex + 1), versions)
}
return null
}
// Handle URLs, git refs, etc. - we can't resolve these
if (
range.startsWith('http://') ||
range.startsWith('https://') ||
range.startsWith('git://') ||
range.startsWith('git+') ||
range.startsWith('file:') ||
range.includes('/')
) {
return null
}
return maxSatisfying(versions, range)
}
/** Resolved package info */
export interface ResolvedPackage {
name: string
version: string
size: number
optional: boolean
/** Depth level (only when trackDepth is enabled) */
depth?: DependencyDepth
/** Dependency path from root (only when trackDepth is enabled) */
path?: string[]
/** Deprecation message if the version is deprecated */
deprecated?: string
/** Original URL if this was a git: or https: dependency */
url?: string
}
/**
* Resolve the entire dependency tree for a package.
* Uses level-by-level BFS to ensure correct depth assignment when trackDepth is enabled.
*/
export async function resolveDependencyTree(
rootName: string,
rootVersion: string,
options: { trackDepth?: boolean } = {},
): Promise<Map<string, ResolvedPackage>> {
const resolved = new Map<string, ResolvedPackage>()
const seen = new Set<string>()
// 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 level = 0
while (currentLevel.size > 0) {
const nextLevel = new Map<string, { range: string; optional: boolean; path: string[] }>()
// Mark all packages in current level as seen before processing
for (const name of currentLevel.keys()) {
seen.add(name)
}
// Process current level with concurrency limit
const entries = [...currentLevel.entries()]
await mapWithConcurrency(
entries,
async ([name, { range, optional, path }]) => {
const packument = await fetchPackument(name)
if (!packument) return
const versions = Object.keys(packument.versions)
const version = resolveVersion(range, versions)
if (!version) return
const versionData = packument.versions[version]
if (!versionData) return
if (!matchesPlatform(versionData)) return
const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0
const key = `${name}@${version}`
// 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, optional }
if (options.trackDepth) {
pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive'
pkg.path = currentPath
}
if (versionData.deprecated) {
pkg.deprecated = versionData.deprecated
}
resolved.set(key, pkg)
}
// Collect dependencies for next level
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 })
}
}
}
// Collect optional dependencies
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 })
}
}
}
},
PACKUMENT_FETCH_CONCURRENCY,
)
currentLevel = nextLevel
level++
}
return resolved
}