@packall/core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/{Archive.d.ts → archive.d.ts} +5 -5
  2. package/dist/archive.d.ts.map +1 -0
  3. package/dist/{Bundle.d.ts → bundle.d.ts} +51 -36
  4. package/dist/bundle.d.ts.map +1 -0
  5. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  6. package/dist/dependency-range.d.ts.map +1 -0
  7. package/dist/{Download.d.ts → download.d.ts} +7 -7
  8. package/dist/download.d.ts.map +1 -0
  9. package/dist/enums/artifact-kind.d.ts +8 -0
  10. package/dist/enums/artifact-kind.d.ts.map +1 -0
  11. package/dist/enums/edge-kind.d.ts +29 -0
  12. package/dist/enums/edge-kind.d.ts.map +1 -0
  13. package/dist/enums/input-file-kind.d.ts +12 -0
  14. package/dist/enums/input-file-kind.d.ts.map +1 -0
  15. package/dist/enums/layout.d.ts +23 -0
  16. package/dist/enums/layout.d.ts.map +1 -0
  17. package/dist/enums/lockfile-format.d.ts +9 -0
  18. package/dist/enums/lockfile-format.d.ts.map +1 -0
  19. package/dist/enums/phase.d.ts +11 -0
  20. package/dist/enums/phase.d.ts.map +1 -0
  21. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/index.d.ts +44 -28
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2416 -1180
  26. package/dist/index.js.map +1 -1
  27. package/dist/input-file.d.ts +77 -0
  28. package/dist/input-file.d.ts.map +1 -0
  29. package/dist/{Integrity.d.ts → integrity.d.ts} +3 -3
  30. package/dist/integrity.d.ts.map +1 -0
  31. package/dist/{Layout.d.ts → layout.d.ts} +1 -1
  32. package/dist/{Layout.d.ts.map → layout.d.ts.map} +1 -1
  33. package/dist/locked-resolve.d.ts +47 -0
  34. package/dist/locked-resolve.d.ts.map +1 -0
  35. package/dist/lockfile/bun.d.ts +3 -0
  36. package/dist/lockfile/bun.d.ts.map +1 -0
  37. package/dist/lockfile/detect.d.ts +31 -0
  38. package/dist/lockfile/detect.d.ts.map +1 -0
  39. package/dist/lockfile/json.d.ts +23 -0
  40. package/dist/lockfile/json.d.ts.map +1 -0
  41. package/dist/lockfile/names.d.ts +16 -0
  42. package/dist/lockfile/names.d.ts.map +1 -0
  43. package/dist/lockfile/npm.d.ts +3 -0
  44. package/dist/lockfile/npm.d.ts.map +1 -0
  45. package/dist/lockfile/parse.d.ts +23 -0
  46. package/dist/lockfile/parse.d.ts.map +1 -0
  47. package/dist/lockfile/pnpm.d.ts +20 -0
  48. package/dist/lockfile/pnpm.d.ts.map +1 -0
  49. package/dist/lockfile/tree-builder.d.ts +70 -0
  50. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  51. package/dist/lockfile/types.d.ts +70 -0
  52. package/dist/lockfile/types.d.ts.map +1 -0
  53. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  54. package/dist/manifest.d.ts.map +1 -0
  55. package/dist/{Options.d.ts → options.d.ts} +9 -27
  56. package/dist/options.d.ts.map +1 -0
  57. package/dist/{Platform.d.ts → platform.d.ts} +9 -9
  58. package/dist/platform.d.ts.map +1 -0
  59. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  60. package/dist/progress.d.ts.map +1 -0
  61. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  62. package/dist/registry.d.ts.map +1 -0
  63. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  64. package/dist/resolve.d.ts.map +1 -0
  65. package/dist/schemas/lenient.d.ts +38 -0
  66. package/dist/schemas/lenient.d.ts.map +1 -0
  67. package/dist/schemas/package-json.d.ts +44 -0
  68. package/dist/schemas/package-json.d.ts.map +1 -0
  69. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/types/value-of.d.ts +9 -0
  72. package/dist/types/value-of.d.ts.map +1 -0
  73. package/dist/utils/is-record.d.ts +8 -0
  74. package/dist/utils/is-record.d.ts.map +1 -0
  75. package/package.json +3 -2
  76. package/src/{Archive.ts → archive.ts} +92 -92
  77. package/src/{Bundle.ts → bundle.ts} +616 -570
  78. package/src/{DependencyRange.ts → dependency-range.ts} +115 -115
  79. package/src/{Download.ts → download.ts} +133 -132
  80. package/src/enums/artifact-kind.ts +9 -0
  81. package/src/enums/edge-kind.ts +32 -0
  82. package/src/enums/input-file-kind.ts +13 -0
  83. package/src/enums/layout.ts +25 -0
  84. package/src/enums/lockfile-format.ts +10 -0
  85. package/src/enums/phase.ts +12 -0
  86. package/src/{Errors.ts → errors.ts} +87 -5
  87. package/src/index.ts +133 -35
  88. package/src/{InputFile.ts → input-file.ts} +127 -44
  89. package/src/{Integrity.ts → integrity.ts} +121 -122
  90. package/src/locked-resolve.ts +461 -0
  91. package/src/lockfile/bun.ts +207 -0
  92. package/src/lockfile/detect.ts +80 -0
  93. package/src/lockfile/json.ts +132 -0
  94. package/src/lockfile/names.ts +20 -0
  95. package/src/lockfile/npm.ts +308 -0
  96. package/src/lockfile/parse.ts +56 -0
  97. package/src/lockfile/pnpm.ts +225 -0
  98. package/src/lockfile/tree-builder.ts +166 -0
  99. package/src/lockfile/types.ts +74 -0
  100. package/src/{Manifest.ts → manifest.ts} +133 -133
  101. package/src/{Options.ts → options.ts} +7 -27
  102. package/src/{Platform.ts → platform.ts} +23 -23
  103. package/src/{Progress.ts → progress.ts} +106 -106
  104. package/src/{Registry.ts → registry.ts} +115 -121
  105. package/src/{Resolve.ts → resolve.ts} +537 -528
  106. package/src/schemas/lenient.ts +123 -0
  107. package/src/schemas/package-json.ts +27 -0
  108. package/src/{Spec.ts → spec.ts} +11 -19
  109. package/src/types/value-of.ts +8 -0
  110. package/src/utils/is-record.ts +8 -0
  111. package/dist/Archive.d.ts.map +0 -1
  112. package/dist/Bundle.d.ts.map +0 -1
  113. package/dist/DependencyRange.d.ts.map +0 -1
  114. package/dist/Download.d.ts.map +0 -1
  115. package/dist/Errors.d.ts.map +0 -1
  116. package/dist/InputFile.d.ts +0 -46
  117. package/dist/InputFile.d.ts.map +0 -1
  118. package/dist/Integrity.d.ts.map +0 -1
  119. package/dist/Manifest.d.ts.map +0 -1
  120. package/dist/Options.d.ts.map +0 -1
  121. package/dist/Platform.d.ts.map +0 -1
  122. package/dist/Progress.d.ts.map +0 -1
  123. package/dist/Registry.d.ts.map +0 -1
  124. package/dist/Resolve.d.ts.map +0 -1
  125. package/dist/Spec.d.ts.map +0 -1
  126. /package/src/{Layout.ts → layout.ts} +0 -0
@@ -1,528 +1,537 @@
1
- /**
2
- * Version selection and the dependency closure walk.
3
- *
4
- * This is the part that decides *what* ends up in a bundle. Its only side
5
- * effect is asking the `Registry` service for metadata, which makes the whole
6
- * of it testable against a fake registry with no network at all.
7
- *
8
- * The walk is breadth-first in waves: resolve the current frontier
9
- * concurrently, collect the newly discovered edges, repeat. Wave-based BFS
10
- * gives real parallelism without a dynamic worker pool, and because each wave
11
- * merges its results serially there is no shared mutable state being written
12
- * from more than one fiber.
13
- *
14
- * One subtlety worth calling out: metadata is cached across walks, but each
15
- * walk still traverses the full graph. That is what lets `per-spec` layout ask
16
- * "what does *this* root version depend on" many times over while the network
17
- * is touched exactly once per package.
18
- */
19
- import * as Effect from "effect/Effect"
20
- import semver from "semver"
21
- import { parseDependencyTarget } from "./DependencyRange.js"
22
- import type { BundlerError } from "./Errors.js"
23
- import { NoMatchingVersionsError, VersionNotFoundError } from "./Errors.js"
24
- import type { ResolveOptions } from "./Options.js"
25
- import { isIncluded } from "./Platform.js"
26
- import type { Packument, PackageManifest } from "./Registry.js"
27
- import { Registry } from "./Registry.js"
28
- import * as Progress from "./Progress.js"
29
- import type { PackageSpec, Selector } from "./Spec.js"
30
- import { formatSelector, formatSpec } from "./Spec.js"
31
-
32
- /** Why a package is in the bundle. */
33
- export type EdgeKind = "prod" | "optional" | "peer"
34
-
35
- /** A single justification for including a package. */
36
- export type Reason =
37
- | { readonly _tag: "Root"; readonly spec: string }
38
- | { readonly _tag: "Edge"; readonly from: string; readonly kind: EdgeKind }
39
-
40
- /** `name@version` the identity of a resolved package throughout the engine. */
41
- export type PackageKey = string
42
-
43
- export const packageKey = (name: string, version: string): PackageKey => `${name}@${version}`
44
-
45
- /** One package, pinned to one version, with every reason it was pulled in. */
46
- export interface ResolvedPackage {
47
- readonly name: string
48
- readonly version: string
49
- readonly manifest: PackageManifest
50
- readonly reasons: ReadonlyArray<Reason>
51
- }
52
-
53
- /** Something we could not include, reported rather than thrown. */
54
- export interface ResolutionWarning {
55
- readonly message: string
56
- /** `name@version` of the package whose manifest contained the problem. */
57
- readonly from?: string | undefined
58
- }
59
-
60
- /** What one requested spec expanded to. */
61
- export interface RootResolution {
62
- readonly spec: PackageSpec
63
- /** The concrete versions the spec selected — more than one under `--all-versions`. */
64
- readonly versions: ReadonlyArray<string>
65
- /** Closure per selected version, keyed by version. Drives `per-spec` layout. */
66
- readonly closures: ReadonlyMap<string, ReadonlyArray<PackageKey>>
67
- /** Union of every per-version closure. */
68
- readonly closure: ReadonlyArray<PackageKey>
69
- }
70
-
71
- /** The complete result of resolving every requested spec. */
72
- export interface Resolution {
73
- readonly roots: ReadonlyArray<RootResolution>
74
- /** The deduplicated union of every closure, sorted by name then version. */
75
- readonly packages: ReadonlyArray<ResolvedPackage>
76
- readonly warnings: ReadonlyArray<ResolutionWarning>
77
- }
78
-
79
- /** Index of a resolution by `name@version`, for lookups during bundling. */
80
- export const indexPackages = (
81
- resolution: Resolution
82
- ): ReadonlyMap<PackageKey, ResolvedPackage> =>
83
- new Map(resolution.packages.map((p) => [packageKey(p.name, p.version), p]))
84
-
85
- /* -------------------------------------------------------------------------- */
86
- /* Version selection */
87
- /* -------------------------------------------------------------------------- */
88
-
89
- /**
90
- * Turns a selector into the concrete version(s) it names.
91
- *
92
- * `all` controls whether a range collapses to its best match — npm's behaviour,
93
- * and what you always want for a transitive dependency — or expands to every
94
- * satisfying published version, which is what `--all-versions` is for.
95
- */
96
- export const selectVersions = (
97
- packument: Packument,
98
- selector: Selector,
99
- options: {
100
- readonly all: boolean
101
- readonly maxVersions?: number | undefined
102
- readonly includePrerelease: boolean
103
- }
104
- ): ReadonlyArray<string> => {
105
- const available = Object.keys(packument.versions)
106
-
107
- switch (selector._tag) {
108
- case "Exact": {
109
- if (!Object.hasOwn(packument.versions, selector.version)) {
110
- throw new VersionNotFoundError(packument.name, selector.version, sortVersions(available))
111
- }
112
- return [selector.version]
113
- }
114
-
115
- case "Tag": {
116
- const version = packument.distTags[selector.tag]
117
- if (version === undefined || !Object.hasOwn(packument.versions, version)) {
118
- throw new NoMatchingVersionsError(packument.name, selector.tag, sortVersions(available))
119
- }
120
- return [version]
121
- }
122
-
123
- case "Range": {
124
- const satisfying = available
125
- .filter((v) =>
126
- semver.satisfies(v, selector.range, {
127
- loose: true,
128
- includePrerelease: options.includePrerelease
129
- })
130
- )
131
- .sort(semver.rcompare)
132
-
133
- if (satisfying.length === 0) {
134
- throw new NoMatchingVersionsError(packument.name, selector.range, sortVersions(available))
135
- }
136
-
137
- if (!options.all) return [satisfying[0]!]
138
-
139
- return options.maxVersions !== undefined && options.maxVersions > 0
140
- ? satisfying.slice(0, options.maxVersions)
141
- : satisfying
142
- }
143
- }
144
- }
145
-
146
- const sortVersions = (versions: ReadonlyArray<string>): ReadonlyArray<string> =>
147
- [...versions].sort((a, b) =>
148
- semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b)
149
- )
150
-
151
- /* -------------------------------------------------------------------------- */
152
- /* Walk state */
153
- /* -------------------------------------------------------------------------- */
154
-
155
- /** One pending item on the BFS frontier. */
156
- export interface Task {
157
- readonly name: string
158
- readonly selector: Selector
159
- readonly reason: Reason
160
- /** Optional edges tolerate resolution failure; required ones do not. */
161
- readonly tolerateFailure: boolean
162
- }
163
-
164
- /**
165
- * State shared across every walk in a run.
166
- *
167
- * Caching packuments and expanded edges here is what makes repeated walks
168
- * cheap: the second walk over `react@18.2.0` costs no network at all.
169
- */
170
- interface WalkState {
171
- readonly resolved: Map<PackageKey, ResolvedPackage>
172
- readonly edges: Map<PackageKey, ReadonlyArray<Task>>
173
- readonly packuments: Map<string, Packument>
174
- readonly warnings: Array<ResolutionWarning>
175
- /** Warnings are deduplicated — the same skipped edge appears in many walks. */
176
- readonly warningKeys: Set<string>
177
- }
178
-
179
- const makeWalkState = (): WalkState => ({
180
- resolved: new Map(),
181
- edges: new Map(),
182
- packuments: new Map(),
183
- warnings: [],
184
- warningKeys: new Set()
185
- })
186
-
187
- const addWarning = (state: WalkState, warning: ResolutionWarning): void => {
188
- const key = `${warning.from ?? ""}|${warning.message}`
189
- if (state.warningKeys.has(key)) return
190
- state.warningKeys.add(key)
191
- state.warnings.push(warning)
192
- }
193
-
194
- /**
195
- * Fetches a packument, reusing anything already seen in this run.
196
- *
197
- * Two fibers in the same wave can race here and both fetch. That is one
198
- * duplicated GET at worst, and de-racing it would cost more complexity than it
199
- * saves.
200
- */
201
- const getPackument = (
202
- state: WalkState,
203
- name: string
204
- ): Effect.Effect<Packument, BundlerError, Registry> =>
205
- Effect.gen(function*() {
206
- const cached = state.packuments.get(name)
207
- if (cached !== undefined) return cached
208
- const registry = yield* Registry
209
- const packument = yield* registry.packument(name)
210
- state.packuments.set(name, packument)
211
- return packument
212
- })
213
-
214
- /* -------------------------------------------------------------------------- */
215
- /* Edge expansion */
216
- /* -------------------------------------------------------------------------- */
217
-
218
- /** Expands one resolved manifest into the edges that follow from it. */
219
- export const edgesOf = (
220
- manifest: PackageManifest,
221
- options: ResolveOptions
222
- ): {
223
- readonly tasks: ReadonlyArray<Task>
224
- readonly warnings: ReadonlyArray<ResolutionWarning>
225
- } => {
226
- const from = packageKey(manifest.name, manifest.version)
227
- const tasks: Array<Task> = []
228
- const warnings: Array<ResolutionWarning> = []
229
-
230
- const add = (
231
- entries: Readonly<Record<string, string>> | undefined,
232
- kind: EdgeKind,
233
- tolerateFailure: boolean,
234
- skip?: (name: string) => boolean
235
- ): void => {
236
- if (entries === undefined) return
237
- for (const [name, raw] of Object.entries(entries)) {
238
- if (skip?.(name)) continue
239
- const target = parseDependencyTarget(name, raw)
240
- if (target._tag === "Unsupported") {
241
- warnings.push({
242
- from,
243
- message:
244
- `Skipped ${name}@${target.raw} (${target.reason}) — it cannot be fetched from a ` +
245
- `registry. Vendor it separately if the offline install needs it.`
246
- })
247
- continue
248
- }
249
- tasks.push({
250
- name: target.name,
251
- selector: target.selector,
252
- reason: { _tag: "Edge", from, kind },
253
- tolerateFailure
254
- })
255
- }
256
- }
257
-
258
- // Runtime dependencies are non-negotiable.
259
- add(manifest.dependencies, "prod", false)
260
-
261
- // Optional dependencies are allowed to be missing — npm tolerates a 404 here
262
- // and so must we, or one unpublished platform package fails the whole run.
263
- if (options.scope.optional) {
264
- add(manifest.optionalDependencies, "optional", true)
265
- }
266
-
267
- if (options.scope.peer) {
268
- const meta = manifest.peerDependenciesMeta ?? {}
269
- add(manifest.peerDependencies, "peer", false, (name) => meta[name]?.optional === true)
270
- }
271
-
272
- return { tasks, warnings }
273
- }
274
-
275
- /** Edges for a package, computed once and cached. */
276
- const edgesFor = (
277
- state: WalkState,
278
- manifest: PackageManifest,
279
- options: ResolveOptions
280
- ): ReadonlyArray<Task> => {
281
- const key = packageKey(manifest.name, manifest.version)
282
- const cached = state.edges.get(key)
283
- if (cached !== undefined) return cached
284
-
285
- const { tasks, warnings } = edgesOf(manifest, options)
286
- for (const warning of warnings) addWarning(state, warning)
287
- state.edges.set(key, tasks)
288
- return tasks
289
- }
290
-
291
- /* -------------------------------------------------------------------------- */
292
- /* The walk */
293
- /* -------------------------------------------------------------------------- */
294
-
295
- /**
296
- * Resolves everything reachable from `seeds`, returning the closure.
297
- *
298
- * `state` is mutated with newly resolved packages and warnings; the returned
299
- * array is scoped to *this* walk, which is what makes per-root-version
300
- * closures possible.
301
- */
302
- const walk = (
303
- state: WalkState,
304
- seeds: ReadonlyArray<Task>,
305
- options: ResolveOptions
306
- ): Effect.Effect<ReadonlyArray<PackageKey>, BundlerError, Registry | Progress.Progress> =>
307
- Effect.gen(function*() {
308
- const discovered = new Set<PackageKey>()
309
- // Guards against re-resolving the same (name, selector) pair. Large trees
310
- // ask for `tslib@^2` hundreds of times, and this is also what terminates
311
- // dependency cycles.
312
- const attempted = new Set<string>()
313
- let frontier: ReadonlyArray<Task> = seeds
314
-
315
- while (frontier.length > 0) {
316
- const wave: Array<Task> = []
317
- for (const task of frontier) {
318
- const id = `${task.name} ${formatSelector(task.selector)}`
319
- if (attempted.has(id)) continue
320
- attempted.add(id)
321
- wave.push(task)
322
- }
323
- if (wave.length === 0) break
324
-
325
- const results = yield* Effect.forEach(
326
- wave,
327
- (task) => resolveTask(state, task, options),
328
- { concurrency: options.concurrency }
329
- )
330
-
331
- const next: Array<Task> = []
332
-
333
- // Merged serially: every mutation of `state` happens on one fiber.
334
- for (const result of results) {
335
- if (result === null) continue
336
-
337
- for (const { manifest, reason } of result) {
338
- const key = packageKey(manifest.name, manifest.version)
339
- const isNew = !state.resolved.has(key)
340
-
341
- if (isNew) {
342
- state.resolved.set(key, {
343
- name: manifest.name,
344
- version: manifest.version,
345
- manifest,
346
- reasons: [reason]
347
- })
348
-
349
- yield* Progress.emit({
350
- _tag: "PackageResolved",
351
- name: manifest.name,
352
- version: manifest.version,
353
- resolvedCount: state.resolved.size,
354
- pendingCount: next.length
355
- })
356
-
357
- if (manifest.deprecated !== undefined && manifest.deprecated !== "") {
358
- addWarning(state, { from: key, message: `${key} is deprecated: ${manifest.deprecated}` })
359
- }
360
- } else {
361
- const existing = state.resolved.get(key)!
362
- if (!hasReason(existing.reasons, reason)) {
363
- state.resolved.set(key, { ...existing, reasons: [...existing.reasons, reason] })
364
- }
365
- }
366
-
367
- // Expand regardless of whether the package is new. A package resolved
368
- // during an earlier walk still has to contribute its subtree to
369
- // *this* walk's closure; `attempted` stops that from looping.
370
- if (!discovered.has(key)) {
371
- discovered.add(key)
372
- next.push(...edgesFor(state, manifest, options))
373
- }
374
- }
375
- }
376
-
377
- frontier = next
378
- }
379
-
380
- return [...discovered]
381
- })
382
-
383
- const hasReason = (reasons: ReadonlyArray<Reason>, candidate: Reason): boolean =>
384
- reasons.some((reason) => {
385
- if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec
386
- if (reason._tag === "Edge" && candidate._tag === "Edge") {
387
- return reason.from === candidate.from && reason.kind === candidate.kind
388
- }
389
- return false
390
- })
391
-
392
- /**
393
- * Resolves one task to its manifest(s).
394
- *
395
- * Returns `null` when an optional edge could not be resolved — the caller
396
- * treats that as "skip quietly", which is what npm does for
397
- * `optionalDependencies`.
398
- */
399
- const resolveTask = (
400
- state: WalkState,
401
- task: Task,
402
- options: ResolveOptions
403
- ): Effect.Effect<
404
- ReadonlyArray<{ manifest: PackageManifest; reason: Reason }> | null,
405
- BundlerError,
406
- Registry
407
- > =>
408
- Effect.gen(function*() {
409
- const packument = yield* getPackument(state, task.name)
410
-
411
- // Transitive edges always collapse to a single best match; only root specs
412
- // ever fan out, and those arrive here already pinned to an exact version.
413
- const versions = yield* Effect.try({
414
- try: () =>
415
- selectVersions(packument, task.selector, {
416
- all: false,
417
- includePrerelease: options.includePrerelease
418
- }),
419
- catch: (error) => error as BundlerError
420
- })
421
-
422
- const out: Array<{ manifest: PackageManifest; reason: Reason }> = []
423
- for (const version of versions) {
424
- const manifest = packument.versions[version]
425
- if (manifest === undefined) continue
426
-
427
- // Platform narrowing only ever applies to optional edges. Filtering a
428
- // required dependency by platform would produce a bundle that cannot
429
- // install anywhere.
430
- if (
431
- task.reason._tag === "Edge" &&
432
- task.reason.kind === "optional" &&
433
- !isIncluded(manifest, options.scope.platforms)
434
- ) {
435
- continue
436
- }
437
-
438
- out.push({ manifest, reason: task.reason })
439
- }
440
- return out
441
- }).pipe(
442
- Effect.catch((error) => {
443
- // A required edge that cannot be resolved is fatal. An optional one is
444
- // recorded and skipped, matching how npm treats `optionalDependencies`.
445
- if (!task.tolerateFailure) return Effect.fail(error)
446
- return Effect.sync(() => {
447
- addWarning(state, {
448
- message:
449
- `Optional dependency ${task.name}@${formatSelector(task.selector)} could not be ` +
450
- `resolved and was skipped: ${error.message}`
451
- })
452
- return null
453
- })
454
- })
455
- )
456
-
457
- /* -------------------------------------------------------------------------- */
458
- /* Entry point */
459
- /* -------------------------------------------------------------------------- */
460
-
461
- /**
462
- * Resolves every requested spec.
463
- *
464
- * Each selected root *version* is walked separately so `per-spec` layout has an
465
- * exact closure per tarball, while all walks share one cache, so a package
466
- * reached from twenty roots is fetched once.
467
- */
468
- export const resolve = (
469
- specs: ReadonlyArray<PackageSpec>,
470
- options: ResolveOptions
471
- ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
472
- Effect.gen(function*() {
473
- yield* Progress.emit({ _tag: "PhaseStarted", phase: "resolve", total: specs.length })
474
-
475
- const state = makeWalkState()
476
- const roots: Array<RootResolution> = []
477
-
478
- for (const spec of specs) {
479
- const packument = yield* getPackument(state, spec.name)
480
-
481
- const versions = yield* Effect.try({
482
- try: () =>
483
- selectVersions(packument, spec.selector, {
484
- all: options.allVersions,
485
- maxVersions: options.maxVersions,
486
- includePrerelease: options.includePrerelease
487
- }),
488
- catch: (error) => error as BundlerError
489
- })
490
-
491
- const closures = new Map<string, ReadonlyArray<PackageKey>>()
492
- const union = new Set<PackageKey>()
493
-
494
- for (const version of versions) {
495
- const closure = yield* walk(
496
- state,
497
- [
498
- {
499
- name: spec.name,
500
- selector: { _tag: "Exact", version },
501
- reason: { _tag: "Root", spec: formatSpec(spec) },
502
- tolerateFailure: false
503
- }
504
- ],
505
- options
506
- )
507
- closures.set(version, closure)
508
- for (const key of closure) union.add(key)
509
- }
510
-
511
- roots.push({ spec, versions, closures, closure: [...union] })
512
- }
513
-
514
- yield* Progress.emit({ _tag: "PhaseCompleted", phase: "resolve" })
515
-
516
- return {
517
- roots,
518
- packages: [...state.resolved.values()].sort(compareResolved),
519
- warnings: state.warnings
520
- }
521
- })
522
-
523
- const compareResolved = (a: ResolvedPackage, b: ResolvedPackage): number => {
524
- if (a.name !== b.name) return a.name < b.name ? -1 : 1
525
- return semver.valid(a.version) && semver.valid(b.version)
526
- ? semver.compare(a.version, b.version)
527
- : a.version.localeCompare(b.version)
528
- }
1
+ /**
2
+ * Version selection and the dependency closure walk.
3
+ *
4
+ * This is the part that decides *what* ends up in a bundle. Its only side
5
+ * effect is asking the `Registry` service for metadata, which makes the whole
6
+ * of it testable against a fake registry with no network at all.
7
+ *
8
+ * The walk is breadth-first in waves: resolve the current frontier
9
+ * concurrently, collect the newly discovered edges, repeat. Wave-based BFS
10
+ * gives real parallelism without a dynamic worker pool, and because each wave
11
+ * merges its results serially there is no shared mutable state being written
12
+ * from more than one fiber.
13
+ *
14
+ * One subtlety worth calling out: metadata is cached across walks, but each
15
+ * walk still traverses the full graph. That is what lets `per-spec` layout ask
16
+ * "what does *this* root version depend on" many times over while the network
17
+ * is touched exactly once per package.
18
+ */
19
+ import * as Effect from "effect/Effect"
20
+ import semver from "semver"
21
+ import { parseDependencyTarget } from "./dependency-range.js"
22
+ import { EdgeKind } from "./enums/edge-kind.js"
23
+ import { Phase } from "./enums/phase.js"
24
+ import type { BundlerError } from "./errors.js"
25
+ import { NoMatchingVersionsError, VersionNotFoundError } from "./errors.js"
26
+ import type { ResolveOptions } from "./options.js"
27
+ import { isIncluded } from "./platform.js"
28
+ import * as Progress from "./progress.js"
29
+ import type { PackageManifest, Packument } from "./registry.js"
30
+ import { Registry } from "./registry.js"
31
+ import type { PackageSpec, Selector } from "./spec.js"
32
+ import { formatSelector, formatSpec } from "./spec.js"
33
+
34
+ /** A single justification for including a package. */
35
+ export type Reason =
36
+ | { readonly _tag: "Root"; readonly spec: string }
37
+ | { readonly _tag: "Edge"; readonly from: string; readonly kind: EdgeKind }
38
+
39
+ /** `name@version` — the identity of a resolved package throughout the engine. */
40
+ export type PackageKey = string
41
+
42
+ /** One package, pinned to one version, with every reason it was pulled in. */
43
+ export type ResolvedPackage = {
44
+ readonly name: string
45
+ readonly version: string
46
+ readonly manifest: PackageManifest
47
+ readonly reasons: ReadonlyArray<Reason>
48
+ }
49
+
50
+ /** Something we could not include, reported rather than thrown. */
51
+ export type ResolutionWarning = {
52
+ readonly message: string
53
+ /** `name@version` of the package whose manifest contained the problem. */
54
+ readonly from?: string | undefined
55
+ }
56
+
57
+ /** What one requested spec expanded to. */
58
+ export type RootResolution = {
59
+ readonly spec: PackageSpec
60
+ /** The concrete versions the spec selected more than one under `--all-versions`. */
61
+ readonly versions: ReadonlyArray<string>
62
+ /** Closure per selected version, keyed by version. Drives `per-spec` layout. */
63
+ readonly closures: ReadonlyMap<string, ReadonlyArray<PackageKey>>
64
+ /** Union of every per-version closure. */
65
+ readonly closure: ReadonlyArray<PackageKey>
66
+ }
67
+
68
+ /** The complete result of resolving every requested spec. */
69
+ export type Resolution = {
70
+ readonly roots: ReadonlyArray<RootResolution>
71
+ /** The deduplicated union of every closure, sorted by name then version. */
72
+ readonly packages: ReadonlyArray<ResolvedPackage>
73
+ readonly warnings: ReadonlyArray<ResolutionWarning>
74
+ }
75
+
76
+ /** One pending item on the BFS frontier. */
77
+ export type Task = {
78
+ readonly name: string
79
+ readonly selector: Selector
80
+ readonly reason: Reason
81
+ /** Optional edges tolerate resolution failure; required ones do not. */
82
+ readonly tolerateFailure: boolean
83
+ }
84
+
85
+ export const packageKey = (name: string, version: string): PackageKey => `${name}@${version}`
86
+
87
+ /** Index of a resolution by `name@version`, for lookups during bundling. */
88
+ export const indexPackages = (
89
+ resolution: Resolution
90
+ ): ReadonlyMap<PackageKey, ResolvedPackage> =>
91
+ new Map(resolution.packages.map((pkg) => [packageKey(pkg.name, pkg.version), pkg]))
92
+
93
+ /* -------------------------------------------------------------------------- */
94
+ /* Version selection */
95
+ /* -------------------------------------------------------------------------- */
96
+
97
+ /**
98
+ * Turns a selector into the concrete version(s) it names.
99
+ *
100
+ * `all` controls whether a range collapses to its best match — npm's behaviour,
101
+ * and what you always want for a transitive dependency — or expands to every
102
+ * satisfying published version, which is what `--all-versions` is for.
103
+ */
104
+ export const selectVersions = (
105
+ packument: Packument,
106
+ selector: Selector,
107
+ options: {
108
+ readonly all: boolean
109
+ readonly maxVersions?: number | undefined
110
+ readonly includePrerelease: boolean
111
+ }
112
+ ): ReadonlyArray<string> => {
113
+ const available = Object.keys(packument.versions)
114
+
115
+ switch (selector._tag) {
116
+ case "Exact": {
117
+ if (!Object.hasOwn(packument.versions, selector.version)) {
118
+ throw new VersionNotFoundError(packument.name, selector.version, sortVersions(available))
119
+ }
120
+ return [selector.version]
121
+ }
122
+
123
+ case "Tag": {
124
+ const version = packument.distTags[selector.tag]
125
+ if (version === undefined || !Object.hasOwn(packument.versions, version)) {
126
+ throw new NoMatchingVersionsError(packument.name, selector.tag, sortVersions(available))
127
+ }
128
+ return [version]
129
+ }
130
+
131
+ case "Range": {
132
+ const satisfying = available
133
+ .filter((version) =>
134
+ semver.satisfies(version, selector.range, {
135
+ loose: true,
136
+ includePrerelease: options.includePrerelease
137
+ })
138
+ )
139
+ .toSorted(semver.rcompare)
140
+
141
+ const [best] = satisfying
142
+ if (best === undefined) {
143
+ throw new NoMatchingVersionsError(packument.name, selector.range, sortVersions(available))
144
+ }
145
+
146
+ if (!options.all) return [best]
147
+
148
+ return options.maxVersions !== undefined && options.maxVersions > 0
149
+ ? satisfying.slice(0, options.maxVersions)
150
+ : satisfying
151
+ }
152
+ }
153
+ }
154
+
155
+ const sortVersions = (versions: ReadonlyArray<string>): ReadonlyArray<string> =>
156
+ versions.toSorted((a, b) =>
157
+ semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b)
158
+ )
159
+
160
+ /* -------------------------------------------------------------------------- */
161
+ /* Walk state */
162
+ /* -------------------------------------------------------------------------- */
163
+
164
+ /**
165
+ * State shared across every walk in a run.
166
+ *
167
+ * Caching packuments and expanded edges here is what makes repeated walks
168
+ * cheap: the second walk over `react@18.2.0` costs no network at all.
169
+ */
170
+ type WalkState = {
171
+ readonly resolved: Map<PackageKey, ResolvedPackage>
172
+ readonly edges: Map<PackageKey, ReadonlyArray<Task>>
173
+ readonly packuments: Map<string, Packument>
174
+ readonly warnings: Array<ResolutionWarning>
175
+ /** Warnings are deduplicated — the same skipped edge appears in many walks. */
176
+ readonly warningKeys: Set<string>
177
+ }
178
+
179
+ const makeWalkState = (): WalkState => ({
180
+ resolved: new Map(),
181
+ edges: new Map(),
182
+ packuments: new Map(),
183
+ warnings: [],
184
+ warningKeys: new Set()
185
+ })
186
+
187
+ const addWarning = (state: WalkState, warning: ResolutionWarning): void => {
188
+ const key = `${warning.from ?? ""}|${warning.message}`
189
+ if (state.warningKeys.has(key)) return
190
+ state.warningKeys.add(key)
191
+ state.warnings.push(warning)
192
+ }
193
+
194
+ /**
195
+ * Fetches a packument, reusing anything already seen in this run.
196
+ *
197
+ * Two fibers in the same wave can race here and both fetch. That is one
198
+ * duplicated GET at worst, and de-racing it would cost more complexity than it
199
+ * saves.
200
+ */
201
+ const getPackument = (
202
+ state: WalkState,
203
+ name: string
204
+ ): Effect.Effect<Packument, BundlerError, Registry> =>
205
+ Effect.gen(function*() {
206
+ const cached = state.packuments.get(name)
207
+ if (cached !== undefined) return cached
208
+ const registry = yield* Registry
209
+ const packument = yield* registry.packument(name)
210
+ state.packuments.set(name, packument)
211
+ return packument
212
+ })
213
+
214
+ /**
215
+ * Re-fails a thrown selection error into the Effect error channel.
216
+ *
217
+ * `selectVersions` throws `BundlerError` subclasses and nothing else, so this
218
+ * narrows rather than casting; anything unexpected is rethrown as a defect,
219
+ * which is what an unexpected exception actually is.
220
+ */
221
+ const asBundlerError = (error: unknown): BundlerError => {
222
+ if (error instanceof VersionNotFoundError || error instanceof NoMatchingVersionsError) {
223
+ return error
224
+ }
225
+ throw error
226
+ }
227
+
228
+ /* -------------------------------------------------------------------------- */
229
+ /* Edge expansion */
230
+ /* -------------------------------------------------------------------------- */
231
+
232
+ /** Expands one resolved manifest into the edges that follow from it. */
233
+ export const edgesOf = (
234
+ manifest: PackageManifest,
235
+ options: ResolveOptions
236
+ ): {
237
+ readonly tasks: ReadonlyArray<Task>
238
+ readonly warnings: ReadonlyArray<ResolutionWarning>
239
+ } => {
240
+ const from = packageKey(manifest.name, manifest.version)
241
+ const tasks: Array<Task> = []
242
+ const warnings: Array<ResolutionWarning> = []
243
+
244
+ const add = (
245
+ entries: Readonly<Record<string, string>> | undefined,
246
+ kind: EdgeKind,
247
+ tolerateFailure: boolean,
248
+ skip?: (name: string) => boolean
249
+ ): void => {
250
+ if (entries === undefined) return
251
+ for (const [name, raw] of Object.entries(entries)) {
252
+ if (skip?.(name)) continue
253
+ const target = parseDependencyTarget(name, raw)
254
+ if (target._tag === "Unsupported") {
255
+ warnings.push({
256
+ from,
257
+ message:
258
+ `Skipped ${name}@${target.raw} (${target.reason}) it cannot be fetched from a ` +
259
+ `registry. Vendor it separately if the offline install needs it.`
260
+ })
261
+ continue
262
+ }
263
+ tasks.push({
264
+ name: target.name,
265
+ selector: target.selector,
266
+ reason: { _tag: "Edge", from, kind },
267
+ tolerateFailure
268
+ })
269
+ }
270
+ }
271
+
272
+ // Runtime dependencies are non-negotiable.
273
+ add(manifest.dependencies, EdgeKind.Prod, false)
274
+
275
+ // Optional dependencies are allowed to be missing npm tolerates a 404 here
276
+ // and so must we, or one unpublished platform package fails the whole run.
277
+ if (options.scope.optional) {
278
+ add(manifest.optionalDependencies, EdgeKind.Optional, true)
279
+ }
280
+
281
+ if (options.scope.peer) {
282
+ const meta = manifest.peerDependenciesMeta ?? {}
283
+ add(manifest.peerDependencies, EdgeKind.Peer, false, (name) => meta[name]?.optional === true)
284
+ }
285
+
286
+ return { tasks, warnings }
287
+ }
288
+
289
+ /** Edges for a package, computed once and cached. */
290
+ const edgesFor = (
291
+ state: WalkState,
292
+ manifest: PackageManifest,
293
+ options: ResolveOptions
294
+ ): ReadonlyArray<Task> => {
295
+ const key = packageKey(manifest.name, manifest.version)
296
+ const cached = state.edges.get(key)
297
+ if (cached !== undefined) return cached
298
+
299
+ const { tasks, warnings } = edgesOf(manifest, options)
300
+ for (const warning of warnings) addWarning(state, warning)
301
+ state.edges.set(key, tasks)
302
+ return tasks
303
+ }
304
+
305
+ /* -------------------------------------------------------------------------- */
306
+ /* The walk */
307
+ /* -------------------------------------------------------------------------- */
308
+
309
+ /**
310
+ * Resolves everything reachable from `seeds`, returning the closure.
311
+ *
312
+ * `state` is mutated with newly resolved packages and warnings; the returned
313
+ * array is scoped to *this* walk, which is what makes per-root-version
314
+ * closures possible.
315
+ */
316
+ const walk = (
317
+ state: WalkState,
318
+ seeds: ReadonlyArray<Task>,
319
+ options: ResolveOptions
320
+ ): Effect.Effect<ReadonlyArray<PackageKey>, BundlerError, Registry | Progress.Progress> =>
321
+ Effect.gen(function*() {
322
+ const discovered = new Set<PackageKey>()
323
+ // Guards against re-resolving the same (name, selector) pair. Large trees
324
+ // ask for `tslib@^2` hundreds of times, and this is also what terminates
325
+ // dependency cycles.
326
+ const attempted = new Set<string>()
327
+ let frontier: ReadonlyArray<Task> = seeds
328
+
329
+ while (frontier.length > 0) {
330
+ const wave: Array<Task> = []
331
+ for (const task of frontier) {
332
+ const id = `${task.name} ${formatSelector(task.selector)}`
333
+ if (attempted.has(id)) continue
334
+ attempted.add(id)
335
+ wave.push(task)
336
+ }
337
+ if (wave.length === 0) break
338
+
339
+ const results = yield* Effect.forEach(
340
+ wave,
341
+ (task) => resolveTask(state, task, options),
342
+ { concurrency: options.concurrency }
343
+ )
344
+
345
+ const next: Array<Task> = []
346
+
347
+ // Merged serially: every mutation of `state` happens on one fiber.
348
+ for (const result of results) {
349
+ if (result === null) continue
350
+
351
+ for (const { manifest, reason } of result) {
352
+ const key = packageKey(manifest.name, manifest.version)
353
+ const existing = state.resolved.get(key)
354
+
355
+ if (existing === undefined) {
356
+ state.resolved.set(key, {
357
+ name: manifest.name,
358
+ version: manifest.version,
359
+ manifest,
360
+ reasons: [reason]
361
+ })
362
+
363
+ yield* Progress.emit({
364
+ _tag: "PackageResolved",
365
+ name: manifest.name,
366
+ version: manifest.version,
367
+ resolvedCount: state.resolved.size,
368
+ pendingCount: next.length
369
+ })
370
+
371
+ if (manifest.deprecated !== undefined && manifest.deprecated !== "") {
372
+ addWarning(state, { from: key, message: `${key} is deprecated: ${manifest.deprecated}` })
373
+ }
374
+ } else if (!hasReason(existing.reasons, reason)) {
375
+ state.resolved.set(key, { ...existing, reasons: [...existing.reasons, reason] })
376
+ }
377
+
378
+ // Expand regardless of whether the package is new. A package resolved
379
+ // during an earlier walk still has to contribute its subtree to
380
+ // *this* walk's closure; `attempted` stops that from looping.
381
+ if (!discovered.has(key)) {
382
+ discovered.add(key)
383
+ next.push(...edgesFor(state, manifest, options))
384
+ }
385
+ }
386
+ }
387
+
388
+ frontier = next
389
+ }
390
+
391
+ return [...discovered]
392
+ })
393
+
394
+ const hasReason = (reasons: ReadonlyArray<Reason>, candidate: Reason): boolean =>
395
+ reasons.some((reason) => {
396
+ if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec
397
+ if (reason._tag === "Edge" && candidate._tag === "Edge") {
398
+ return reason.from === candidate.from && reason.kind === candidate.kind
399
+ }
400
+ return false
401
+ })
402
+
403
+ /**
404
+ * Resolves one task to its manifest(s).
405
+ *
406
+ * Returns `null` when an optional edge could not be resolved — the caller
407
+ * treats that as "skip quietly", which is what npm does for
408
+ * `optionalDependencies`.
409
+ */
410
+ const resolveTask = (
411
+ state: WalkState,
412
+ task: Task,
413
+ options: ResolveOptions
414
+ ): Effect.Effect<
415
+ ReadonlyArray<{ readonly manifest: PackageManifest; readonly reason: Reason }> | null,
416
+ BundlerError,
417
+ Registry
418
+ > =>
419
+ Effect.gen(function*() {
420
+ const packument = yield* getPackument(state, task.name)
421
+
422
+ // Transitive edges always collapse to a single best match; only root specs
423
+ // ever fan out, and those arrive here already pinned to an exact version.
424
+ const versions = yield* Effect.try({
425
+ try: () =>
426
+ selectVersions(packument, task.selector, {
427
+ all: false,
428
+ includePrerelease: options.includePrerelease
429
+ }),
430
+ catch: asBundlerError
431
+ })
432
+
433
+ return versions.flatMap((version) => {
434
+ const manifest = packument.versions[version]
435
+ if (manifest === undefined) return []
436
+
437
+ // Platform narrowing only ever applies to optional edges. Filtering a
438
+ // required dependency by platform would produce a bundle that cannot
439
+ // install anywhere.
440
+ if (
441
+ task.reason._tag === "Edge" &&
442
+ task.reason.kind === EdgeKind.Optional &&
443
+ !isIncluded(manifest, options.scope.platforms)
444
+ ) {
445
+ return []
446
+ }
447
+
448
+ return { manifest, reason: task.reason }
449
+ })
450
+ }).pipe(
451
+ Effect.catch((error) => {
452
+ // A required edge that cannot be resolved is fatal. An optional one is
453
+ // recorded and skipped, matching how npm treats `optionalDependencies`.
454
+ if (!task.tolerateFailure) return Effect.fail(error)
455
+ return Effect.sync(() => {
456
+ addWarning(state, {
457
+ message:
458
+ `Optional dependency ${task.name}@${formatSelector(task.selector)} could not be ` +
459
+ `resolved and was skipped: ${error.message}`
460
+ })
461
+ return null
462
+ })
463
+ })
464
+ )
465
+
466
+ /* -------------------------------------------------------------------------- */
467
+ /* Entry point */
468
+ /* -------------------------------------------------------------------------- */
469
+
470
+ /**
471
+ * Resolves every requested spec.
472
+ *
473
+ * Each selected root *version* is walked separately so `per-spec` layout has an
474
+ * exact closure per tarball, while all walks share one cache, so a package
475
+ * reached from twenty roots is fetched once.
476
+ */
477
+ export const resolve = (
478
+ specs: ReadonlyArray<PackageSpec>,
479
+ options: ResolveOptions
480
+ ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
481
+ Effect.gen(function*() {
482
+ yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Resolve, total: specs.length })
483
+
484
+ const state = makeWalkState()
485
+ const roots: Array<RootResolution> = []
486
+
487
+ for (const spec of specs) {
488
+ const packument = yield* getPackument(state, spec.name)
489
+
490
+ const versions = yield* Effect.try({
491
+ try: () =>
492
+ selectVersions(packument, spec.selector, {
493
+ all: options.allVersions,
494
+ maxVersions: options.maxVersions,
495
+ includePrerelease: options.includePrerelease
496
+ }),
497
+ catch: asBundlerError
498
+ })
499
+
500
+ const closures = new Map<string, ReadonlyArray<PackageKey>>()
501
+ const union = new Set<PackageKey>()
502
+
503
+ for (const version of versions) {
504
+ const closure = yield* walk(
505
+ state,
506
+ [
507
+ {
508
+ name: spec.name,
509
+ selector: { _tag: "Exact", version },
510
+ reason: { _tag: "Root", spec: formatSpec(spec) },
511
+ tolerateFailure: false
512
+ }
513
+ ],
514
+ options
515
+ )
516
+ closures.set(version, closure)
517
+ for (const key of closure) union.add(key)
518
+ }
519
+
520
+ roots.push({ spec, versions, closures, closure: [...union] })
521
+ }
522
+
523
+ yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Resolve })
524
+
525
+ return {
526
+ roots,
527
+ packages: [...state.resolved.values()].toSorted(compareResolved),
528
+ warnings: state.warnings
529
+ }
530
+ })
531
+
532
+ const compareResolved = (a: ResolvedPackage, b: ResolvedPackage): number => {
533
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1
534
+ return semver.valid(a.version) && semver.valid(b.version)
535
+ ? semver.compare(a.version, b.version)
536
+ : a.version.localeCompare(b.version)
537
+ }