@packall/core 0.1.0 → 0.3.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-DmF9XNl7.js +284 -0
  4. package/dist/archive-DmF9XNl7.js.map +1 -0
  5. package/dist/archive.d.ts +47 -15
  6. package/dist/archive.d.ts.map +1 -1
  7. package/dist/bundle.d.ts +6 -3
  8. package/dist/bundle.d.ts.map +1 -1
  9. package/dist/dependency-range.d.ts.map +1 -1
  10. package/dist/download.d.ts +2 -1
  11. package/dist/download.d.ts.map +1 -1
  12. package/dist/enums/artifact-kind.d.ts.map +1 -1
  13. package/dist/enums/edge-kind.d.ts.map +1 -1
  14. package/dist/enums/input-file-kind.d.ts.map +1 -1
  15. package/dist/enums/layout.d.ts.map +1 -1
  16. package/dist/enums/lockfile-format.d.ts.map +1 -1
  17. package/dist/enums/phase.d.ts.map +1 -1
  18. package/dist/errors.d.ts +11 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/index.d.ts +12 -10
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +169 -344
  23. package/dist/index.js.map +1 -1
  24. package/dist/input-file.d.ts.map +1 -1
  25. package/dist/integrity.d.ts +37 -5
  26. package/dist/integrity.d.ts.map +1 -1
  27. package/dist/layout.d.ts +41 -7
  28. package/dist/layout.d.ts.map +1 -1
  29. package/dist/locked-resolve.d.ts.map +1 -1
  30. package/dist/lockfile/bun.d.ts.map +1 -1
  31. package/dist/lockfile/detect.d.ts.map +1 -1
  32. package/dist/lockfile/json.d.ts.map +1 -1
  33. package/dist/lockfile/names.d.ts.map +1 -1
  34. package/dist/lockfile/npm.d.ts.map +1 -1
  35. package/dist/lockfile/parse.d.ts.map +1 -1
  36. package/dist/lockfile/pnpm.d.ts.map +1 -1
  37. package/dist/lockfile/tree-builder.d.ts.map +1 -1
  38. package/dist/lockfile/types.d.ts.map +1 -1
  39. package/dist/manifest.d.ts.map +1 -1
  40. package/dist/node/archiver.d.ts +5 -0
  41. package/dist/node/archiver.d.ts.map +1 -0
  42. package/dist/node/index.d.ts +13 -0
  43. package/dist/node/index.d.ts.map +1 -0
  44. package/dist/node/index.js +55 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/options.d.ts +3 -3
  47. package/dist/options.d.ts.map +1 -1
  48. package/dist/platform.d.ts +5 -10
  49. package/dist/platform.d.ts.map +1 -1
  50. package/dist/progress.d.ts.map +1 -1
  51. package/dist/registry.d.ts.map +1 -1
  52. package/dist/resolve.d.ts.map +1 -1
  53. package/dist/schemas/lenient.d.ts.map +1 -1
  54. package/dist/schemas/package-json.d.ts.map +1 -1
  55. package/dist/spec.d.ts.map +1 -1
  56. package/dist/types/value-of.d.ts.map +1 -1
  57. package/dist/utils/is-record.d.ts.map +1 -1
  58. package/package.json +11 -10
  59. package/src/archive.ts +95 -92
  60. package/src/bundle.ts +736 -616
  61. package/src/dependency-range.ts +121 -115
  62. package/src/download.ts +155 -133
  63. package/src/enums/artifact-kind.ts +5 -5
  64. package/src/enums/edge-kind.ts +13 -13
  65. package/src/enums/input-file-kind.ts +9 -9
  66. package/src/enums/layout.ts +20 -20
  67. package/src/enums/lockfile-format.ts +6 -6
  68. package/src/enums/phase.ts +8 -8
  69. package/src/errors.ts +211 -199
  70. package/src/index.ts +126 -113
  71. package/src/input-file.ts +246 -234
  72. package/src/integrity.ts +165 -121
  73. package/src/layout.ts +63 -29
  74. package/src/locked-resolve.ts +471 -461
  75. package/src/lockfile/bun.ts +214 -207
  76. package/src/lockfile/detect.ts +80 -80
  77. package/src/lockfile/json.ts +91 -92
  78. package/src/lockfile/names.ts +9 -9
  79. package/src/lockfile/npm.ts +328 -308
  80. package/src/lockfile/parse.ts +32 -32
  81. package/src/lockfile/pnpm.ts +230 -225
  82. package/src/lockfile/tree-builder.ts +116 -116
  83. package/src/lockfile/types.ts +52 -52
  84. package/src/manifest.ts +129 -133
  85. package/src/node/archiver.ts +85 -0
  86. package/src/node/index.ts +13 -0
  87. package/src/options.ts +84 -84
  88. package/src/platform.ts +97 -101
  89. package/src/progress.ts +105 -106
  90. package/src/registry.ts +116 -115
  91. package/src/resolve.ts +560 -537
  92. package/src/schemas/lenient.ts +67 -60
  93. package/src/schemas/package-json.ts +11 -10
  94. package/src/spec.ts +123 -119
  95. package/src/types/value-of.ts +1 -1
  96. package/src/utils/is-record.ts +1 -1
@@ -1,461 +1,471 @@
1
- /**
2
- * Resolution driven by a lockfile instead of by semver.
3
- *
4
- * The distinction matters more than it first looks. Feeding a lockfile's
5
- * versions in as exact root specs and letting the ordinary walk run would still
6
- * produce the wrong bundle: every *transitive* edge is a range, and
7
- * `Resolve.walk` answers a range with whatever is newest today. Lock `tslib` at
8
- * 2.6.0 and let something depending on `tslib@^2` be re-resolved, and the
9
- * bundle grows a second copy at 2.8.1 that CI never installed.
10
- *
11
- * So the lockfile is treated as the graph, not as a set of hints. The package
12
- * set *is* what the file pins, and every edge is satisfied from inside it.
13
- * Nothing here calls `selectVersions`; the only thing the registry is asked for
14
- * is the metadata that a lockfile does not record — `dist`, and the `os`/`cpu`
15
- * constraints that `--platform` narrows on.
16
- *
17
- * The result is an ordinary `Resolution`, so layout, archiving, the manifest
18
- * and the summary cannot tell which path produced it.
19
- */
20
- import * as Effect from "effect/Effect"
21
- import semver from "semver"
22
- import type { EdgeKind } from "./enums/edge-kind.js"
23
- import { LockedRootKind } from "./enums/edge-kind.js"
24
- import { Phase } from "./enums/phase.js"
25
- import type { BundlerError } from "./errors.js"
26
- import { LockfileIncompleteError, LockfileOutOfDateError } from "./errors.js"
27
- import type { LockedRoot, LockedTree } from "./lockfile/types.js"
28
- import type { ResolveOptions } from "./options.js"
29
- import { isIncluded } from "./platform.js"
30
- import * as Progress from "./progress.js"
31
- import type { PackageManifest } from "./registry.js"
32
- import { Registry } from "./registry.js"
33
- import type {
34
- PackageKey,
35
- Reason,
36
- Resolution,
37
- ResolutionWarning,
38
- ResolvedPackage,
39
- RootResolution
40
- } from "./resolve.js"
41
- import { packageKey } from "./resolve.js"
42
- import type { PackageSpec } from "./spec.js"
43
- import { formatSpec } from "./spec.js"
44
-
45
- /** Options for a lockfile-pinned resolution. */
46
- export type LockedResolveOptions = ResolveOptions & {
47
- /**
48
- * Include the project's `devDependencies`.
49
- *
50
- * Matches `--file package.json`: on by default, because a project without its
51
- * dev tooling does not build behind the firewall either.
52
- */
53
- readonly includeDev: boolean
54
- /**
55
- * Direct dependency names declared by the accompanying package.json.
56
- *
57
- * When present, anything declared there but absent from the lockfile's roots
58
- * means the two have drifted which is exactly the silent-mismatch case this
59
- * whole feature exists to catch, so it is reported rather than papered over.
60
- */
61
- readonly declared?: ReadonlyArray<string> | undefined
62
- }
63
-
64
- /** Turns a parsed lockfile into a resolution, pinning every version. */
65
- export const resolveLocked = (
66
- lock: LockedTree,
67
- options: LockedResolveOptions
68
- ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
69
- Effect.gen(function* () {
70
- const roots = selectRoots(lock, options)
71
-
72
- yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Resolve, total: roots.length })
73
-
74
- const gap = findGaps(lock, roots, options.declared)
75
- if (gap !== null) return yield* Effect.fail(gap)
76
-
77
- // Everything the lockfile can reach, before the platform filter has had a
78
- // say. Cheap no network at all and it is what decides the fetch list.
79
- const reachable = reachableKeys(lock, roots, options)
80
- const manifests = yield* fetchManifests(lock, reachable, options)
81
-
82
- const warnings: Array<ResolutionWarning> = lock.warnings.map((message) => ({ message }))
83
- const resolved = new Map<PackageKey, ResolvedPackage>()
84
- const seenWarnings = new Set<string>()
85
-
86
- const addWarning = (warning: ResolutionWarning): void => {
87
- const key = `${warning.from ?? ""}|${warning.message}`
88
- if (seenWarnings.has(key)) return
89
- seenWarnings.add(key)
90
- warnings.push(warning)
91
- }
92
-
93
- const rootResolutions: Array<RootResolution> = []
94
-
95
- for (const root of roots) {
96
- const key = packageKey(root.name, root.version)
97
- if (!manifests.has(key)) continue
98
-
99
- const spec: PackageSpec = {
100
- name: root.name,
101
- selector: { _tag: "Exact", version: root.version },
102
- raw: `${root.name}@${root.version}`
103
- }
104
-
105
- // Platform narrowing applies to optional roots for the same reason it
106
- // applies to optional edges: a native binding for another OS is exactly
107
- // what --platform is there to drop.
108
- if (root.kind === LockedRootKind.Optional && !platformAllows(manifests, key, options)) continue
109
-
110
- const closure = walkLocked({
111
- lock,
112
- manifests,
113
- options,
114
- seed: key,
115
- rootSpec: formatSpec(spec),
116
- resolved,
117
- addWarning
118
- })
119
-
120
- rootResolutions.push({
121
- spec,
122
- versions: [root.version],
123
- closures: new Map([[root.version, closure]]),
124
- closure
125
- })
126
- }
127
-
128
- for (const pkg of resolved.values()) {
129
- const deprecated = pkg.manifest.deprecated
130
- if (deprecated !== undefined && deprecated !== "") {
131
- addWarning({
132
- from: packageKey(pkg.name, pkg.version),
133
- message: `${packageKey(pkg.name, pkg.version)} is deprecated: ${deprecated}`
134
- })
135
- }
136
- }
137
-
138
- yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Resolve })
139
-
140
- return {
141
- roots: rootResolutions,
142
- packages: [...resolved.values()].toSorted(compareResolved),
143
- warnings
144
- }
145
- })
146
-
147
- /* -------------------------------------------------------------------------- */
148
- /* Roots */
149
- /* -------------------------------------------------------------------------- */
150
-
151
- /** Which of the project's direct dependencies this run should bundle. */
152
- const selectRoots = (
153
- lock: LockedTree,
154
- options: LockedResolveOptions
155
- ): ReadonlyArray<LockedRoot> =>
156
- lock.roots
157
- .filter((root) => {
158
- if (root.kind === LockedRootKind.Dev) return options.includeDev
159
- if (root.kind === LockedRootKind.Optional) return options.scope.optional
160
- if (root.kind === LockedRootKind.Peer) return options.scope.peer
161
- return true
162
- })
163
- // Sorted so the artifact order is a property of the dependency set rather
164
- // than of which manager wrote the file.
165
- .toSorted((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1))
166
-
167
- /**
168
- * The two ways a lockfile can be partial, checked before anything is fetched.
169
- *
170
- * Both mean the same thing to whoever is waiting on the bundle a package they
171
- * expected will not be in it — and both are invisible unless something says so,
172
- * because the run otherwise succeeds and simply produces less.
173
- */
174
- const findGaps = (
175
- lock: LockedTree,
176
- roots: ReadonlyArray<LockedRoot>,
177
- declared: ReadonlyArray<string> | undefined
178
- ): LockfileIncompleteError | null => {
179
- const remedy = `Run \`${lock.format} install\` to bring the lockfile up to date.`
180
-
181
- // A dependency added to package.json without re-running the install. The
182
- // commonest way the two drift, and the cheapest to detect.
183
- //
184
- // Checked against everything the file pins rather than against its roots,
185
- // because the managers disagree about which blocks appear in the root entry —
186
- // pnpm's importers omit peers, npm's `packages[""]` includes them and a
187
- // package that is pinned somewhere will be in the bundle either way.
188
- if (declared !== undefined && declared.length > 0) {
189
- const locked = new Set<string>()
190
- for (const pkg of lock.packages.values()) locked.add(pkg.name)
191
- for (const root of roots) locked.add(root.name)
192
-
193
- const missing = declared.filter((name) => !locked.has(name))
194
- if (missing.length > 0) {
195
- return new LockfileIncompleteError(
196
- lock.path,
197
- missing,
198
- `the package.json declares ${missing.length} ` +
199
- `dependenc${missing.length === 1 ? "y" : "ies"} it does not pin`,
200
- remedy
201
- )
202
- }
203
- }
204
-
205
- // A required edge inside the file pointing at nothing the file pins.
206
- if (lock.incomplete.length > 0) {
207
- return new LockfileIncompleteError(
208
- lock.path,
209
- lock.incomplete,
210
- `${lock.incomplete.length} required ` +
211
- `dependenc${lock.incomplete.length === 1 ? "y is" : "ies are"} not pinned`,
212
- remedy
213
- )
214
- }
215
-
216
- return null
217
- }
218
-
219
- /* -------------------------------------------------------------------------- */
220
- /* Reachability */
221
- /* -------------------------------------------------------------------------- */
222
-
223
- /**
224
- * Every package the roots can reach through the locked edges.
225
- *
226
- * Scope filters apply here `--no-optional` genuinely removes a subtree but
227
- * the platform filter does not, because deciding it needs the `os`/`cpu` fields
228
- * that only the registry has.
229
- */
230
- const reachableKeys = (
231
- lock: LockedTree,
232
- roots: ReadonlyArray<LockedRoot>,
233
- options: LockedResolveOptions
234
- ): ReadonlySet<PackageKey> => {
235
- const seen = new Set<PackageKey>()
236
- const queue = roots.map((root) => packageKey(root.name, root.version))
237
-
238
- while (queue.length > 0) {
239
- const key = queue.pop()
240
- if (key === undefined || seen.has(key)) continue
241
- seen.add(key)
242
-
243
- for (const edge of lock.packages.get(key)?.dependencies ?? []) {
244
- if (!followsEdge(edge.kind, options)) continue
245
- queue.push(packageKey(edge.name, edge.version))
246
- }
247
- }
248
-
249
- return seen
250
- }
251
-
252
- const followsEdge = (kind: EdgeKind, options: LockedResolveOptions): boolean => {
253
- if (kind === LockedRootKind.Optional) return options.scope.optional
254
- if (kind === LockedRootKind.Peer) return options.scope.peer
255
- return true
256
- }
257
-
258
- /* -------------------------------------------------------------------------- */
259
- /* Metadata */
260
- /* -------------------------------------------------------------------------- */
261
-
262
- /**
263
- * Fetches the published manifest for every pinned version.
264
- *
265
- * One packument per distinct *name*, not per version a lockfile holding four
266
- * versions of `tslib` costs one request. Every pinned version that the registry
267
- * does not serve is collected and reported together, because "refresh your
268
- * lockfile" is a single action and finding out about the gaps one run at a time
269
- * would be miserable.
270
- */
271
- const fetchManifests = (
272
- lock: LockedTree,
273
- reachable: ReadonlySet<PackageKey>,
274
- options: LockedResolveOptions
275
- ): Effect.Effect<
276
- ReadonlyMap<PackageKey, PackageManifest>,
277
- BundlerError,
278
- Registry | Progress.Progress
279
- > =>
280
- Effect.gen(function* () {
281
- const registry = yield* Registry
282
-
283
- const wanted = new Map<string, Array<string>>()
284
- for (const key of reachable) {
285
- const pkg = lock.packages.get(key)
286
- if (pkg === undefined) continue
287
- const versions = wanted.get(pkg.name)
288
- if (versions === undefined) wanted.set(pkg.name, [pkg.version])
289
- else versions.push(pkg.version)
290
- }
291
-
292
- const manifests = new Map<PackageKey, PackageManifest>()
293
- const missing: Array<{ name: string; version: string; detail: string }> = []
294
-
295
- const fetched = yield* Effect.forEach(
296
- [...wanted.entries()],
297
- ([name, versions]) =>
298
- registry.packument(name).pipe(
299
- Effect.map((packument) => ({ name, versions, packument })),
300
- // A package the registry has never heard of is the same problem as a
301
- // version it no longer serves, and belongs in the same report.
302
- Effect.catch((error) =>
303
- error._tag === "PackageNotFoundError"
304
- ? Effect.succeed({ name, versions, packument: null })
305
- : Effect.fail(error)
306
- )
307
- ),
308
- { concurrency: options.concurrency }
309
- )
310
-
311
- for (const { name, versions, packument } of fetched) {
312
- for (const version of versions) {
313
- if (packument === null) {
314
- missing.push({ name, version, detail: "package not found" })
315
- continue
316
- }
317
-
318
- const manifest = packument.versions[version]
319
- if (manifest === undefined) {
320
- missing.push({ name, version, detail: describeAvailable(Object.keys(packument.versions)) })
321
- continue
322
- }
323
-
324
- manifests.set(packageKey(name, version), manifest)
325
- yield* Progress.emit({
326
- _tag: "PackageResolved",
327
- name,
328
- version,
329
- resolvedCount: manifests.size,
330
- pendingCount: reachable.size - manifests.size
331
- })
332
- }
333
- }
334
-
335
- if (missing.length > 0) {
336
- return yield* Effect.fail(
337
- new LockfileOutOfDateError(lock.path, registry.registryFor(missing[0]?.name ?? ""), missing)
338
- )
339
- }
340
-
341
- return manifests
342
- })
343
-
344
- const describeAvailable = (available: ReadonlyArray<string>): string => {
345
- const recent = available
346
- .toSorted((a, b) =>
347
- semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b)
348
- )
349
- .slice(-3)
350
- return recent.length === 0
351
- ? "no published versions"
352
- : `no longer published (latest: ${recent.join(", ")})`
353
- }
354
-
355
- /* -------------------------------------------------------------------------- */
356
- /* The walk */
357
- /* -------------------------------------------------------------------------- */
358
-
359
- /**
360
- * Collects one root's closure by following locked edges.
361
- *
362
- * Pure and synchronous — every manifest is already in hand — so a per-root
363
- * closure costs nothing beyond the traversal, which is what makes `per-spec`
364
- * layout as cheap here as it is for a range-resolved run.
365
- */
366
- const walkLocked = (input: {
367
- readonly lock: LockedTree
368
- readonly manifests: ReadonlyMap<PackageKey, PackageManifest>
369
- readonly options: LockedResolveOptions
370
- readonly seed: PackageKey
371
- readonly rootSpec: string
372
- readonly resolved: Map<PackageKey, ResolvedPackage>
373
- readonly addWarning: (warning: ResolutionWarning) => void
374
- }): ReadonlyArray<PackageKey> => {
375
- const closure = new Set<PackageKey>()
376
- const queue: Array<{ key: PackageKey; reason: Reason }> = [
377
- { key: input.seed, reason: { _tag: "Root", spec: input.rootSpec } }
378
- ]
379
-
380
- while (queue.length > 0) {
381
- const next = queue.pop()
382
- if (next === undefined) continue
383
-
384
- const manifest = input.manifests.get(next.key)
385
- const locked = input.lock.packages.get(next.key)
386
- if (manifest === undefined || locked === undefined) continue
387
-
388
- record(input.resolved, next.key, manifest, locked, next.reason)
389
-
390
- if (closure.has(next.key)) continue
391
- closure.add(next.key)
392
-
393
- for (const edge of locked.dependencies) {
394
- if (!followsEdge(edge.kind, input.options)) continue
395
-
396
- const target = packageKey(edge.name, edge.version)
397
- if (!input.manifests.has(target)) continue
398
-
399
- if (edge.kind === LockedRootKind.Optional && !platformAllows(input.manifests, target, input.options)) {
400
- input.addWarning({
401
- from: next.key,
402
- message:
403
- `Skipped optional ${target} it does not build for the selected platforms.`
404
- })
405
- continue
406
- }
407
-
408
- queue.push({ key: target, reason: { _tag: "Edge", from: next.key, kind: edge.kind } })
409
- }
410
- }
411
-
412
- return [...closure]
413
- }
414
-
415
- /** Adds a package, or merges one more justification into an existing entry. */
416
- const record = (
417
- resolved: Map<PackageKey, ResolvedPackage>,
418
- key: PackageKey,
419
- manifest: PackageManifest,
420
- locked: { readonly name: string; readonly version: string },
421
- reason: Reason
422
- ): void => {
423
- const existing = resolved.get(key)
424
- if (existing === undefined) {
425
- resolved.set(key, {
426
- name: locked.name,
427
- version: locked.version,
428
- manifest,
429
- reasons: [reason]
430
- })
431
- return
432
- }
433
- if (hasReason(existing.reasons, reason)) return
434
- resolved.set(key, { ...existing, reasons: [...existing.reasons, reason] })
435
- }
436
-
437
- const hasReason = (reasons: ReadonlyArray<Reason>, candidate: Reason): boolean =>
438
- reasons.some((reason) => {
439
- if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec
440
- if (reason._tag === "Edge" && candidate._tag === "Edge") {
441
- return reason.from === candidate.from && reason.kind === candidate.kind
442
- }
443
- return false
444
- })
445
-
446
- const platformAllows = (
447
- manifests: ReadonlyMap<PackageKey, PackageManifest>,
448
- key: PackageKey,
449
- options: LockedResolveOptions
450
- ): boolean => {
451
- const manifest = manifests.get(key)
452
- if (manifest === undefined) return false
453
- return isIncluded(manifest, options.scope.platforms)
454
- }
455
-
456
- const compareResolved = (a: ResolvedPackage, b: ResolvedPackage): number => {
457
- if (a.name !== b.name) return a.name < b.name ? -1 : 1
458
- return semver.valid(a.version) && semver.valid(b.version)
459
- ? semver.compare(a.version, b.version)
460
- : a.version.localeCompare(b.version)
461
- }
1
+ /**
2
+ * Resolution driven by a lockfile instead of by semver.
3
+ *
4
+ * The distinction matters more than it first looks. Feeding a lockfile's
5
+ * versions in as exact root specs and letting the ordinary walk run would still
6
+ * produce the wrong bundle: every *transitive* edge is a range, and
7
+ * `Resolve.walk` answers a range with whatever is newest today. Lock `tslib` at
8
+ * 2.6.0 and let something depending on `tslib@^2` be re-resolved, and the
9
+ * bundle grows a second copy at 2.8.1 that CI never installed.
10
+ *
11
+ * So the lockfile is treated as the graph, not as a set of hints. The package
12
+ * set *is* what the file pins, and every edge is satisfied from inside it.
13
+ * Nothing here calls `selectVersions`; the only thing the registry is asked for
14
+ * is the metadata that a lockfile does not record — `dist`, and the `os`/`cpu`
15
+ * constraints that `--platform` narrows on.
16
+ *
17
+ * The result is an ordinary `Resolution`, so layout, archiving, the manifest
18
+ * and the summary cannot tell which path produced it.
19
+ */
20
+ import * as Effect from "effect/Effect";
21
+ import semver from "semver";
22
+
23
+ import type { EdgeKind } from "./enums/edge-kind.js";
24
+ import { LockedRootKind } from "./enums/edge-kind.js";
25
+ import { Phase } from "./enums/phase.js";
26
+ import type { BundlerError } from "./errors.js";
27
+ import { LockfileIncompleteError, LockfileOutOfDateError } from "./errors.js";
28
+ import type { LockedRoot, LockedTree } from "./lockfile/types.js";
29
+ import type { ResolveOptions } from "./options.js";
30
+ import { isIncluded } from "./platform.js";
31
+ import * as Progress from "./progress.js";
32
+ import type { PackageManifest } from "./registry.js";
33
+ import { Registry } from "./registry.js";
34
+ import type {
35
+ PackageKey,
36
+ Reason,
37
+ Resolution,
38
+ ResolutionWarning,
39
+ ResolvedPackage,
40
+ RootResolution,
41
+ } from "./resolve.js";
42
+ import { packageKey } from "./resolve.js";
43
+ import type { PackageSpec } from "./spec.js";
44
+ import { formatSpec } from "./spec.js";
45
+
46
+ /** Options for a lockfile-pinned resolution. */
47
+ export type LockedResolveOptions = ResolveOptions & {
48
+ /**
49
+ * Include the project's `devDependencies`.
50
+ *
51
+ * Matches `--file package.json`: on by default, because a project without its
52
+ * dev tooling does not build behind the firewall either.
53
+ */
54
+ readonly includeDev: boolean;
55
+ /**
56
+ * Direct dependency names declared by the accompanying package.json.
57
+ *
58
+ * When present, anything declared there but absent from the lockfile's roots
59
+ * means the two have drifted which is exactly the silent-mismatch case this
60
+ * whole feature exists to catch, so it is reported rather than papered over.
61
+ */
62
+ readonly declared?: ReadonlyArray<string> | undefined;
63
+ };
64
+
65
+ /** Turns a parsed lockfile into a resolution, pinning every version. */
66
+ export const resolveLocked = (
67
+ lock: LockedTree,
68
+ options: LockedResolveOptions,
69
+ ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
70
+ Effect.gen(function* () {
71
+ const roots = selectRoots(lock, options);
72
+
73
+ yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Resolve, total: roots.length });
74
+
75
+ const gap = findGaps(lock, roots, options.declared);
76
+ if (gap !== null) return yield* Effect.fail(gap);
77
+
78
+ // Everything the lockfile can reach, before the platform filter has had a
79
+ // say. Cheap no network at all — and it is what decides the fetch list.
80
+ const reachable = reachableKeys(lock, roots, options);
81
+ const manifests = yield* fetchManifests(lock, reachable, options);
82
+
83
+ const warnings: Array<ResolutionWarning> = lock.warnings.map((message) => ({ message }));
84
+ const resolved = new Map<PackageKey, ResolvedPackage>();
85
+ const seenWarnings = new Set<string>();
86
+
87
+ const addWarning = (warning: ResolutionWarning): void => {
88
+ const key = `${warning.from ?? ""}|${warning.message}`;
89
+ if (seenWarnings.has(key)) return;
90
+ seenWarnings.add(key);
91
+ warnings.push(warning);
92
+ };
93
+
94
+ const rootResolutions: Array<RootResolution> = [];
95
+
96
+ for (const root of roots) {
97
+ const key = packageKey(root.name, root.version);
98
+ if (!manifests.has(key)) continue;
99
+
100
+ const spec: PackageSpec = {
101
+ name: root.name,
102
+ selector: { _tag: "Exact", version: root.version },
103
+ raw: `${root.name}@${root.version}`,
104
+ };
105
+
106
+ // Platform narrowing applies to optional roots for the same reason it
107
+ // applies to optional edges: a native binding for another OS is exactly
108
+ // what --platform is there to drop.
109
+ if (root.kind === LockedRootKind.Optional && !platformAllows(manifests, key, options))
110
+ continue;
111
+
112
+ const closure = walkLocked({
113
+ lock,
114
+ manifests,
115
+ options,
116
+ seed: key,
117
+ rootSpec: formatSpec(spec),
118
+ resolved,
119
+ addWarning,
120
+ });
121
+
122
+ rootResolutions.push({
123
+ spec,
124
+ versions: [root.version],
125
+ closures: new Map([[root.version, closure]]),
126
+ closure,
127
+ });
128
+ }
129
+
130
+ for (const pkg of resolved.values()) {
131
+ const deprecated = pkg.manifest.deprecated;
132
+ if (deprecated !== undefined && deprecated !== "") {
133
+ addWarning({
134
+ from: packageKey(pkg.name, pkg.version),
135
+ message: `${packageKey(pkg.name, pkg.version)} is deprecated: ${deprecated}`,
136
+ });
137
+ }
138
+ }
139
+
140
+ yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Resolve });
141
+
142
+ return {
143
+ roots: rootResolutions,
144
+ packages: [...resolved.values()].toSorted(compareResolved),
145
+ warnings,
146
+ };
147
+ });
148
+
149
+ /* -------------------------------------------------------------------------- */
150
+ /* Roots */
151
+ /* -------------------------------------------------------------------------- */
152
+
153
+ /** Which of the project's direct dependencies this run should bundle. */
154
+ const selectRoots = (lock: LockedTree, options: LockedResolveOptions): ReadonlyArray<LockedRoot> =>
155
+ lock.roots
156
+ .filter((root) => {
157
+ if (root.kind === LockedRootKind.Dev) return options.includeDev;
158
+ if (root.kind === LockedRootKind.Optional) return options.scope.optional;
159
+ if (root.kind === LockedRootKind.Peer) return options.scope.peer;
160
+ return true;
161
+ })
162
+ // Sorted so the artifact order is a property of the dependency set rather
163
+ // than of which manager wrote the file.
164
+ .toSorted((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1));
165
+
166
+ /**
167
+ * The two ways a lockfile can be partial, checked before anything is fetched.
168
+ *
169
+ * Both mean the same thing to whoever is waiting on the bundle — a package they
170
+ * expected will not be in it and both are invisible unless something says so,
171
+ * because the run otherwise succeeds and simply produces less.
172
+ */
173
+ const findGaps = (
174
+ lock: LockedTree,
175
+ roots: ReadonlyArray<LockedRoot>,
176
+ declared: ReadonlyArray<string> | undefined,
177
+ ): LockfileIncompleteError | null => {
178
+ const remedy = `Run \`${lock.format} install\` to bring the lockfile up to date.`;
179
+
180
+ // A dependency added to package.json without re-running the install. The
181
+ // commonest way the two drift, and the cheapest to detect.
182
+ //
183
+ // Checked against everything the file pins rather than against its roots,
184
+ // because the managers disagree about which blocks appear in the root entry —
185
+ // pnpm's importers omit peers, npm's `packages[""]` includes them and a
186
+ // package that is pinned somewhere will be in the bundle either way.
187
+ if (declared !== undefined && declared.length > 0) {
188
+ const locked = new Set<string>();
189
+ for (const pkg of lock.packages.values()) locked.add(pkg.name);
190
+ for (const root of roots) locked.add(root.name);
191
+
192
+ const missing = declared.filter((name) => !locked.has(name));
193
+ if (missing.length > 0) {
194
+ return new LockfileIncompleteError(
195
+ lock.path,
196
+ missing,
197
+ `the package.json declares ${missing.length} ` +
198
+ `dependenc${missing.length === 1 ? "y" : "ies"} it does not pin`,
199
+ remedy,
200
+ );
201
+ }
202
+ }
203
+
204
+ // A required edge inside the file pointing at nothing the file pins.
205
+ if (lock.incomplete.length > 0) {
206
+ return new LockfileIncompleteError(
207
+ lock.path,
208
+ lock.incomplete,
209
+ `${lock.incomplete.length} required ` +
210
+ `dependenc${lock.incomplete.length === 1 ? "y is" : "ies are"} not pinned`,
211
+ remedy,
212
+ );
213
+ }
214
+
215
+ return null;
216
+ };
217
+
218
+ /* -------------------------------------------------------------------------- */
219
+ /* Reachability */
220
+ /* -------------------------------------------------------------------------- */
221
+
222
+ /**
223
+ * Every package the roots can reach through the locked edges.
224
+ *
225
+ * Scope filters apply here — `--no-optional` genuinely removes a subtree — but
226
+ * the platform filter does not, because deciding it needs the `os`/`cpu` fields
227
+ * that only the registry has.
228
+ */
229
+ const reachableKeys = (
230
+ lock: LockedTree,
231
+ roots: ReadonlyArray<LockedRoot>,
232
+ options: LockedResolveOptions,
233
+ ): ReadonlySet<PackageKey> => {
234
+ const seen = new Set<PackageKey>();
235
+ const queue = roots.map((root) => packageKey(root.name, root.version));
236
+
237
+ while (queue.length > 0) {
238
+ const key = queue.pop();
239
+ if (key === undefined || seen.has(key)) continue;
240
+ seen.add(key);
241
+
242
+ for (const edge of lock.packages.get(key)?.dependencies ?? []) {
243
+ if (!followsEdge(edge.kind, options)) continue;
244
+ queue.push(packageKey(edge.name, edge.version));
245
+ }
246
+ }
247
+
248
+ return seen;
249
+ };
250
+
251
+ const followsEdge = (kind: EdgeKind, options: LockedResolveOptions): boolean => {
252
+ if (kind === LockedRootKind.Optional) return options.scope.optional;
253
+ if (kind === LockedRootKind.Peer) return options.scope.peer;
254
+ return true;
255
+ };
256
+
257
+ /* -------------------------------------------------------------------------- */
258
+ /* Metadata */
259
+ /* -------------------------------------------------------------------------- */
260
+
261
+ /**
262
+ * Fetches the published manifest for every pinned version.
263
+ *
264
+ * One packument per distinct *name*, not per version — a lockfile holding four
265
+ * versions of `tslib` costs one request. Every pinned version that the registry
266
+ * does not serve is collected and reported together, because "refresh your
267
+ * lockfile" is a single action and finding out about the gaps one run at a time
268
+ * would be miserable.
269
+ */
270
+ const fetchManifests = (
271
+ lock: LockedTree,
272
+ reachable: ReadonlySet<PackageKey>,
273
+ options: LockedResolveOptions,
274
+ ): Effect.Effect<
275
+ ReadonlyMap<PackageKey, PackageManifest>,
276
+ BundlerError,
277
+ Registry | Progress.Progress
278
+ > =>
279
+ Effect.gen(function* () {
280
+ const registry = yield* Registry;
281
+
282
+ const wanted = new Map<string, Array<string>>();
283
+ for (const key of reachable) {
284
+ const pkg = lock.packages.get(key);
285
+ if (pkg === undefined) continue;
286
+ const versions = wanted.get(pkg.name);
287
+ if (versions === undefined) wanted.set(pkg.name, [pkg.version]);
288
+ else versions.push(pkg.version);
289
+ }
290
+
291
+ const manifests = new Map<PackageKey, PackageManifest>();
292
+ const missing: Array<{ name: string; version: string; detail: string }> = [];
293
+
294
+ const fetched = yield* Effect.forEach(
295
+ [...wanted.entries()],
296
+ ([name, versions]) =>
297
+ registry.packument(name).pipe(
298
+ Effect.map((packument) => ({ name, versions, packument })),
299
+ // A package the registry has never heard of is the same problem as a
300
+ // version it no longer serves, and belongs in the same report.
301
+ Effect.catch((error) =>
302
+ error._tag === "PackageNotFoundError"
303
+ ? Effect.succeed({ name, versions, packument: null })
304
+ : Effect.fail(error),
305
+ ),
306
+ ),
307
+ { concurrency: options.concurrency },
308
+ );
309
+
310
+ for (const { name, versions, packument } of fetched) {
311
+ for (const version of versions) {
312
+ if (packument === null) {
313
+ missing.push({ name, version, detail: "package not found" });
314
+ continue;
315
+ }
316
+
317
+ const manifest = packument.versions[version];
318
+ if (manifest === undefined) {
319
+ missing.push({
320
+ name,
321
+ version,
322
+ detail: describeAvailable(Object.keys(packument.versions)),
323
+ });
324
+ continue;
325
+ }
326
+
327
+ manifests.set(packageKey(name, version), manifest);
328
+ yield* Progress.emit({
329
+ _tag: "PackageResolved",
330
+ name,
331
+ version,
332
+ resolvedCount: manifests.size,
333
+ pendingCount: reachable.size - manifests.size,
334
+ });
335
+ }
336
+ }
337
+
338
+ if (missing.length > 0) {
339
+ return yield* Effect.fail(
340
+ new LockfileOutOfDateError(
341
+ lock.path,
342
+ registry.registryFor(missing[0]?.name ?? ""),
343
+ missing,
344
+ ),
345
+ );
346
+ }
347
+
348
+ return manifests;
349
+ });
350
+
351
+ const describeAvailable = (available: ReadonlyArray<string>): string => {
352
+ const recent = available
353
+ .toSorted((a, b) =>
354
+ semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b),
355
+ )
356
+ .slice(-3);
357
+ return recent.length === 0
358
+ ? "no published versions"
359
+ : `no longer published (latest: ${recent.join(", ")})`;
360
+ };
361
+
362
+ /* -------------------------------------------------------------------------- */
363
+ /* The walk */
364
+ /* -------------------------------------------------------------------------- */
365
+
366
+ /**
367
+ * Collects one root's closure by following locked edges.
368
+ *
369
+ * Pure and synchronous — every manifest is already in hand — so a per-root
370
+ * closure costs nothing beyond the traversal, which is what makes `per-spec`
371
+ * layout as cheap here as it is for a range-resolved run.
372
+ */
373
+ const walkLocked = (input: {
374
+ readonly lock: LockedTree;
375
+ readonly manifests: ReadonlyMap<PackageKey, PackageManifest>;
376
+ readonly options: LockedResolveOptions;
377
+ readonly seed: PackageKey;
378
+ readonly rootSpec: string;
379
+ readonly resolved: Map<PackageKey, ResolvedPackage>;
380
+ readonly addWarning: (warning: ResolutionWarning) => void;
381
+ }): ReadonlyArray<PackageKey> => {
382
+ const closure = new Set<PackageKey>();
383
+ const queue: Array<{ key: PackageKey; reason: Reason }> = [
384
+ { key: input.seed, reason: { _tag: "Root", spec: input.rootSpec } },
385
+ ];
386
+
387
+ while (queue.length > 0) {
388
+ const next = queue.pop();
389
+ if (next === undefined) continue;
390
+
391
+ const manifest = input.manifests.get(next.key);
392
+ const locked = input.lock.packages.get(next.key);
393
+ if (manifest === undefined || locked === undefined) continue;
394
+
395
+ record(input.resolved, next.key, manifest, locked, next.reason);
396
+
397
+ if (closure.has(next.key)) continue;
398
+ closure.add(next.key);
399
+
400
+ for (const edge of locked.dependencies) {
401
+ if (!followsEdge(edge.kind, input.options)) continue;
402
+
403
+ const target = packageKey(edge.name, edge.version);
404
+ if (!input.manifests.has(target)) continue;
405
+
406
+ if (
407
+ edge.kind === LockedRootKind.Optional &&
408
+ !platformAllows(input.manifests, target, input.options)
409
+ ) {
410
+ input.addWarning({
411
+ from: next.key,
412
+ message: `Skipped optional ${target} — it does not build for the selected platforms.`,
413
+ });
414
+ continue;
415
+ }
416
+
417
+ queue.push({ key: target, reason: { _tag: "Edge", from: next.key, kind: edge.kind } });
418
+ }
419
+ }
420
+
421
+ return [...closure];
422
+ };
423
+
424
+ /** Adds a package, or merges one more justification into an existing entry. */
425
+ const record = (
426
+ resolved: Map<PackageKey, ResolvedPackage>,
427
+ key: PackageKey,
428
+ manifest: PackageManifest,
429
+ locked: { readonly name: string; readonly version: string },
430
+ reason: Reason,
431
+ ): void => {
432
+ const existing = resolved.get(key);
433
+ if (existing === undefined) {
434
+ resolved.set(key, {
435
+ name: locked.name,
436
+ version: locked.version,
437
+ manifest,
438
+ reasons: [reason],
439
+ });
440
+ return;
441
+ }
442
+ if (hasReason(existing.reasons, reason)) return;
443
+ resolved.set(key, { ...existing, reasons: [...existing.reasons, reason] });
444
+ };
445
+
446
+ const hasReason = (reasons: ReadonlyArray<Reason>, candidate: Reason): boolean =>
447
+ reasons.some((reason) => {
448
+ if (reason._tag === "Root" && candidate._tag === "Root")
449
+ return reason.spec === candidate.spec;
450
+ if (reason._tag === "Edge" && candidate._tag === "Edge") {
451
+ return reason.from === candidate.from && reason.kind === candidate.kind;
452
+ }
453
+ return false;
454
+ });
455
+
456
+ const platformAllows = (
457
+ manifests: ReadonlyMap<PackageKey, PackageManifest>,
458
+ key: PackageKey,
459
+ options: LockedResolveOptions,
460
+ ): boolean => {
461
+ const manifest = manifests.get(key);
462
+ if (manifest === undefined) return false;
463
+ return isIncluded(manifest, options.scope.platforms);
464
+ };
465
+
466
+ const compareResolved = (a: ResolvedPackage, b: ResolvedPackage): number => {
467
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1;
468
+ return semver.valid(a.version) && semver.valid(b.version)
469
+ ? semver.compare(a.version, b.version)
470
+ : a.version.localeCompare(b.version);
471
+ };