@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
@@ -0,0 +1,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
+ 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
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `bun.lock`.
3
+ *
4
+ * Like npm, bun records ranges on its edges and resolves them through the
5
+ * install-path nesting — but it nests with a plain `/`, and package names
6
+ * contain `/` too, so the parent of a key can only be found by asking which
7
+ * prefixes are themselves entries.
8
+ */
9
+ import { EdgeKind, LockedRootKind } from "../enums/edge-kind.js"
10
+ import { LockfileFormat } from "../enums/lockfile-format.js"
11
+ import { optionalPeerNames, parseJsonObject, stringRecord } from "./json.js"
12
+ import { isRecord } from "../utils/is-record.js"
13
+ import { asExactVersion, fieldByRootKind, importerKey, notCovered, TreeBuilder } from "./tree-builder.js"
14
+ import type { LockedEdge, LockedTree } from "./types.js"
15
+
16
+ /** One narrowed `packages` entry, keyed by its install path. */
17
+ type BunEntry = {
18
+ readonly key: string
19
+ readonly name: string
20
+ readonly version: string
21
+ readonly dependencies: Readonly<Record<string, string>>
22
+ readonly optionalDependencies: Readonly<Record<string, string>>
23
+ readonly peerDependencies: Readonly<Record<string, string>>
24
+ readonly optionalPeers: ReadonlySet<string>
25
+ }
26
+
27
+ /** Root blocks in bun's precedence order — the first mention of a name wins. */
28
+ const ROOT_KINDS = [
29
+ LockedRootKind.Prod,
30
+ LockedRootKind.Optional,
31
+ LockedRootKind.Dev,
32
+ LockedRootKind.Peer
33
+ ] as const
34
+
35
+ export const parseBunLockfile = (
36
+ path: string,
37
+ content: string,
38
+ importer: string | undefined
39
+ ): LockedTree => {
40
+ const root = parseJsonObject(path, content)
41
+ const version = String(root["lockfileVersion"] ?? "?")
42
+ const builder = new TreeBuilder()
43
+
44
+ const packages = root["packages"]
45
+ const entries = isRecord(packages)
46
+ ? collectEntries(builder, packages)
47
+ : new Map<string, BunEntry>()
48
+
49
+ const keys = new Set(entries.keys())
50
+ for (const entry of entries.values()) {
51
+ builder.add({
52
+ name: entry.name,
53
+ version: entry.version,
54
+ dependencies: edgesOf(builder, entries, keys, entry)
55
+ })
56
+ }
57
+
58
+ collectRoots(builder, entries, keys, root["workspaces"], path, importer)
59
+
60
+ return builder.finish({ format: LockfileFormat.Bun, lockfileVersion: version, path, importer })
61
+ }
62
+
63
+ /**
64
+ * Narrows bun's tuple entries.
65
+ *
66
+ * The value is a positional array whose shape depends on where the package came
67
+ * from. A registry package is `[id, registry, info, integrity]`; a workspace
68
+ * member or a git dependency is shorter and its `id` carries the protocol.
69
+ */
70
+ const collectEntries = (
71
+ builder: TreeBuilder,
72
+ packages: Record<string, unknown>
73
+ ): Map<string, BunEntry> => {
74
+ const entries = new Map<string, BunEntry>()
75
+
76
+ for (const [key, raw] of Object.entries(packages)) {
77
+ if (!Array.isArray(raw)) continue
78
+
79
+ const [id, , info] = raw
80
+ if (typeof id !== "string") continue
81
+
82
+ const at = id.lastIndexOf("@")
83
+ if (at <= 0) continue
84
+ const name = id.slice(0, at)
85
+ const spec = id.slice(at + 1)
86
+
87
+ const version = asExactVersion(name, spec)
88
+ if (version._tag === "Unsupported") {
89
+ builder.skip(name, spec, version.reason)
90
+ continue
91
+ }
92
+
93
+ const details = isRecord(info) ? info : {}
94
+
95
+ entries.set(key, {
96
+ key,
97
+ name,
98
+ version: version.version,
99
+ dependencies: stringRecord(details["dependencies"]),
100
+ optionalDependencies: stringRecord(details["optionalDependencies"]),
101
+ peerDependencies: stringRecord(details["peerDependencies"]),
102
+ optionalPeers: collectOptionalPeers(details)
103
+ })
104
+ }
105
+
106
+ return entries
107
+ }
108
+
109
+ /** bun lists optional peers as a name array rather than npm's metadata object. */
110
+ const collectOptionalPeers = (details: Record<string, unknown>): ReadonlySet<string> => {
111
+ const listed = details["optionalPeers"]
112
+ const names = Array.isArray(listed)
113
+ ? listed.flatMap((entry) => (typeof entry === "string" ? entry : []))
114
+ : []
115
+ return new Set([...names, ...optionalPeerNames(details["peerDependenciesMeta"])])
116
+ }
117
+
118
+ const edgesOf = (
119
+ builder: TreeBuilder,
120
+ entries: ReadonlyMap<string, BunEntry>,
121
+ keys: ReadonlySet<string>,
122
+ entry: BunEntry
123
+ ): ReadonlyArray<LockedEdge> => {
124
+ const edges: Array<LockedEdge> = []
125
+
126
+ const add = (names: ReadonlyArray<string>, kind: EdgeKind): void => {
127
+ for (const name of names) {
128
+ const target = findEntry(entries, keys, entry.key, name)
129
+ if (target === undefined) {
130
+ if (kind === EdgeKind.Prod) builder.dangling(`${entry.name}@${entry.version}`, name)
131
+ continue
132
+ }
133
+ edges.push({ name: target.name, version: target.version, kind })
134
+ }
135
+ }
136
+
137
+ add(Object.keys(entry.dependencies), EdgeKind.Prod)
138
+ add(Object.keys(entry.optionalDependencies), EdgeKind.Optional)
139
+ add(
140
+ Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)),
141
+ EdgeKind.Peer
142
+ )
143
+
144
+ return edges
145
+ }
146
+
147
+ const collectRoots = (
148
+ builder: TreeBuilder,
149
+ entries: ReadonlyMap<string, BunEntry>,
150
+ keys: ReadonlySet<string>,
151
+ workspaces: unknown,
152
+ path: string,
153
+ member: string | undefined
154
+ ): void => {
155
+ if (!isRecord(workspaces)) return
156
+
157
+ const selected =
158
+ member === undefined
159
+ ? Object.values(workspaces)
160
+ : Object.hasOwn(workspaces, importerKey(member))
161
+ ? [workspaces[importerKey(member)]]
162
+ : notCovered(path, member, workspacePaths(workspaces))
163
+
164
+ for (const workspace of selected) {
165
+ if (!isRecord(workspace)) continue
166
+ for (const kind of ROOT_KINDS) {
167
+ const declared = stringRecord(workspace[fieldByRootKind[kind]])
168
+ for (const [name, specifier] of Object.entries(declared)) {
169
+ const target = findEntry(entries, keys, "", name)
170
+ if (target === undefined) continue
171
+ builder.addRoot({ name: target.name, version: target.version, kind, specifier })
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ const workspacePaths = (workspaces: Record<string, unknown>): ReadonlyArray<string> =>
178
+ Object.keys(workspaces).map((key) => (key === "" ? "." : key))
179
+
180
+ /**
181
+ * bun nests with a plain `/`, and package names contain `/` too, so a parent
182
+ * key cannot be found by string-splitting alone — `@babel/core` would split
183
+ * into a scope that is not a key. `parentKey` only accepts a prefix that is
184
+ * itself an entry, which resolves the ambiguity.
185
+ */
186
+ const findEntry = (
187
+ entries: ReadonlyMap<string, BunEntry>,
188
+ keys: ReadonlySet<string>,
189
+ fromKey: string,
190
+ name: string
191
+ ): BunEntry | undefined => {
192
+ let scope = fromKey
193
+ for (;;) {
194
+ const found = entries.get(scope === "" ? name : `${scope}/${name}`)
195
+ if (found !== undefined) return found
196
+ if (scope === "") return undefined
197
+ scope = parentKey(keys, scope)
198
+ }
199
+ }
200
+
201
+ const parentKey = (keys: ReadonlySet<string>, key: string): string => {
202
+ for (let at = key.lastIndexOf("/"); at > 0; at = key.lastIndexOf("/", at - 1)) {
203
+ const prefix = key.slice(0, at)
204
+ if (keys.has(prefix)) return prefix
205
+ }
206
+ return ""
207
+ }