@packall/core 0.0.1 → 0.2.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-6sqTn1C4.js +274 -0
  4. package/dist/archive-6sqTn1C4.js.map +1 -0
  5. package/dist/archive.d.ts +64 -0
  6. package/dist/archive.d.ts.map +1 -0
  7. package/dist/{Bundle.d.ts → bundle.d.ts} +56 -38
  8. package/dist/bundle.d.ts.map +1 -0
  9. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  10. package/dist/dependency-range.d.ts.map +1 -0
  11. package/dist/{Download.d.ts → download.d.ts} +9 -8
  12. package/dist/download.d.ts.map +1 -0
  13. package/dist/enums/artifact-kind.d.ts +8 -0
  14. package/dist/enums/artifact-kind.d.ts.map +1 -0
  15. package/dist/enums/edge-kind.d.ts +29 -0
  16. package/dist/enums/edge-kind.d.ts.map +1 -0
  17. package/dist/enums/input-file-kind.d.ts +12 -0
  18. package/dist/enums/input-file-kind.d.ts.map +1 -0
  19. package/dist/enums/layout.d.ts +23 -0
  20. package/dist/enums/layout.d.ts.map +1 -0
  21. package/dist/enums/lockfile-format.d.ts +9 -0
  22. package/dist/enums/lockfile-format.d.ts.map +1 -0
  23. package/dist/enums/phase.d.ts +11 -0
  24. package/dist/enums/phase.d.ts.map +1 -0
  25. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/index.d.ts +46 -28
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2121 -1060
  30. package/dist/index.js.map +1 -1
  31. package/dist/input-file.d.ts +77 -0
  32. package/dist/input-file.d.ts.map +1 -0
  33. package/dist/integrity.d.ts +80 -0
  34. package/dist/integrity.d.ts.map +1 -0
  35. package/dist/layout.d.ts +87 -0
  36. package/dist/layout.d.ts.map +1 -0
  37. package/dist/locked-resolve.d.ts +47 -0
  38. package/dist/locked-resolve.d.ts.map +1 -0
  39. package/dist/lockfile/bun.d.ts +3 -0
  40. package/dist/lockfile/bun.d.ts.map +1 -0
  41. package/dist/lockfile/detect.d.ts +31 -0
  42. package/dist/lockfile/detect.d.ts.map +1 -0
  43. package/dist/lockfile/json.d.ts +23 -0
  44. package/dist/lockfile/json.d.ts.map +1 -0
  45. package/dist/lockfile/names.d.ts +16 -0
  46. package/dist/lockfile/names.d.ts.map +1 -0
  47. package/dist/lockfile/npm.d.ts +3 -0
  48. package/dist/lockfile/npm.d.ts.map +1 -0
  49. package/dist/lockfile/parse.d.ts +23 -0
  50. package/dist/lockfile/parse.d.ts.map +1 -0
  51. package/dist/lockfile/pnpm.d.ts +20 -0
  52. package/dist/lockfile/pnpm.d.ts.map +1 -0
  53. package/dist/lockfile/tree-builder.d.ts +70 -0
  54. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  55. package/dist/lockfile/types.d.ts +70 -0
  56. package/dist/lockfile/types.d.ts.map +1 -0
  57. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  58. package/dist/manifest.d.ts.map +1 -0
  59. package/dist/node/archiver.d.ts +5 -0
  60. package/dist/node/archiver.d.ts.map +1 -0
  61. package/dist/node/index.d.ts +13 -0
  62. package/dist/node/index.d.ts.map +1 -0
  63. package/dist/node/index.js +55 -0
  64. package/dist/node/index.js.map +1 -0
  65. package/dist/{Options.d.ts → options.d.ts} +12 -30
  66. package/dist/options.d.ts.map +1 -0
  67. package/dist/{Platform.d.ts → platform.d.ts} +14 -19
  68. package/dist/platform.d.ts.map +1 -0
  69. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  70. package/dist/progress.d.ts.map +1 -0
  71. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  72. package/dist/registry.d.ts.map +1 -0
  73. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  74. package/dist/resolve.d.ts.map +1 -0
  75. package/dist/schemas/lenient.d.ts +38 -0
  76. package/dist/schemas/lenient.d.ts.map +1 -0
  77. package/dist/schemas/package-json.d.ts +44 -0
  78. package/dist/schemas/package-json.d.ts.map +1 -0
  79. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  80. package/dist/spec.d.ts.map +1 -0
  81. package/dist/types/value-of.d.ts +9 -0
  82. package/dist/types/value-of.d.ts.map +1 -0
  83. package/dist/utils/is-record.d.ts +8 -0
  84. package/dist/utils/is-record.d.ts.map +1 -0
  85. package/package.json +13 -11
  86. package/src/archive.ts +95 -0
  87. package/src/bundle.ts +736 -0
  88. package/src/dependency-range.ts +121 -0
  89. package/src/download.ts +155 -0
  90. package/src/enums/artifact-kind.ts +9 -0
  91. package/src/enums/edge-kind.ts +32 -0
  92. package/src/enums/input-file-kind.ts +13 -0
  93. package/src/enums/layout.ts +25 -0
  94. package/src/enums/lockfile-format.ts +10 -0
  95. package/src/enums/phase.ts +12 -0
  96. package/src/errors.ts +300 -0
  97. package/src/index.ts +146 -35
  98. package/src/input-file.ts +324 -0
  99. package/src/integrity.ts +165 -0
  100. package/src/layout.ts +137 -0
  101. package/src/locked-resolve.ts +471 -0
  102. package/src/lockfile/bun.ts +214 -0
  103. package/src/lockfile/detect.ts +80 -0
  104. package/src/lockfile/json.ts +131 -0
  105. package/src/lockfile/names.ts +20 -0
  106. package/src/lockfile/npm.ts +328 -0
  107. package/src/lockfile/parse.ts +56 -0
  108. package/src/lockfile/pnpm.ts +230 -0
  109. package/src/lockfile/tree-builder.ts +166 -0
  110. package/src/lockfile/types.ts +74 -0
  111. package/src/manifest.ts +129 -0
  112. package/src/node/archiver.ts +85 -0
  113. package/src/node/index.ts +13 -0
  114. package/src/options.ts +103 -0
  115. package/src/platform.ts +178 -0
  116. package/src/progress.ts +105 -0
  117. package/src/registry.ts +116 -0
  118. package/src/resolve.ts +560 -0
  119. package/src/schemas/lenient.ts +130 -0
  120. package/src/schemas/package-json.ts +28 -0
  121. package/src/spec.ts +182 -0
  122. package/src/types/value-of.ts +8 -0
  123. package/src/utils/is-record.ts +8 -0
  124. package/dist/Archive.d.ts +0 -32
  125. package/dist/Archive.d.ts.map +0 -1
  126. package/dist/Bundle.d.ts.map +0 -1
  127. package/dist/DependencyRange.d.ts.map +0 -1
  128. package/dist/Download.d.ts.map +0 -1
  129. package/dist/Errors.d.ts.map +0 -1
  130. package/dist/InputFile.d.ts +0 -46
  131. package/dist/InputFile.d.ts.map +0 -1
  132. package/dist/Integrity.d.ts +0 -48
  133. package/dist/Integrity.d.ts.map +0 -1
  134. package/dist/Layout.d.ts +0 -53
  135. package/dist/Layout.d.ts.map +0 -1
  136. package/dist/Manifest.d.ts.map +0 -1
  137. package/dist/Options.d.ts.map +0 -1
  138. package/dist/Platform.d.ts.map +0 -1
  139. package/dist/Progress.d.ts.map +0 -1
  140. package/dist/Registry.d.ts.map +0 -1
  141. package/dist/Resolve.d.ts.map +0 -1
  142. package/dist/Spec.d.ts.map +0 -1
  143. package/src/Archive.ts +0 -92
  144. package/src/Bundle.ts +0 -570
  145. package/src/DependencyRange.ts +0 -115
  146. package/src/Download.ts +0 -132
  147. package/src/Errors.ts +0 -216
  148. package/src/InputFile.ts +0 -229
  149. package/src/Integrity.ts +0 -122
  150. package/src/Layout.ts +0 -103
  151. package/src/Manifest.ts +0 -133
  152. package/src/Options.ts +0 -123
  153. package/src/Platform.ts +0 -182
  154. package/src/Progress.ts +0 -106
  155. package/src/Registry.ts +0 -121
  156. package/src/Resolve.ts +0 -528
  157. package/src/Spec.ts +0 -186
package/src/layout.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The on-disk shape of a bundle.
3
+ *
4
+ * Everything is laid out exactly the way a registry serves it:
5
+ *
6
+ * lodash/-/lodash-4.17.21.tgz
7
+ * @babel/core/-/core-7.24.0.tgz
8
+ *
9
+ * That is not an Artifactory convention — it is the path structure in every
10
+ * `dist.tarball` URL npm publishes. See `notes/requirements.md` § FR-12 for
11
+ * what that buys.
12
+ *
13
+ * The one shape worth noticing before editing anything here: a scoped
14
+ * package's file name drops the scope, so `@babel/core` becomes
15
+ * `core-7.24.0.tgz` under an `@babel/core/-/` directory.
16
+ */
17
+
18
+ /** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
19
+ export const splitName = (
20
+ name: string,
21
+ ): { readonly scope: string | undefined; readonly bare: string } => {
22
+ if (!name.startsWith("@")) return { scope: undefined, bare: name };
23
+ const slash = name.indexOf("/");
24
+ if (slash === -1) return { scope: undefined, bare: name };
25
+ return { scope: name.slice(0, slash), bare: name.slice(slash + 1) };
26
+ };
27
+
28
+ /** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
29
+ export const tarballFileName = (name: string, version: string): string => {
30
+ const { bare } = splitName(name);
31
+ return `${bare}-${version}.tgz`;
32
+ };
33
+
34
+ /**
35
+ * The path of one package tarball within a bundle, using `/` separators
36
+ * regardless of host platform — these become tar entry names, and tar entries
37
+ * are always POSIX.
38
+ */
39
+ export const packagePath = (name: string, version: string): string =>
40
+ `${name}/-/${tarballFileName(name, version)}`;
41
+
42
+ /** File name of the manifest that ships inside every bundle. */
43
+ export const MANIFEST_FILE = "bundle-manifest.json";
44
+
45
+ /** File name of the short import guide that ships inside every bundle. */
46
+ export const README_FILE = "IMPORT.md";
47
+
48
+ /**
49
+ * Name of the archive produced for one root spec in `per-spec` layout.
50
+ *
51
+ * It is the spec, with the one character a file name cannot hold taken out:
52
+ * `@types/react@19.2.18` becomes `@types-react@19.2.18.tgz`. So the name of the
53
+ * file answers the question somebody has when they are looking at a directory
54
+ * of them — which package, and which version — in the notation they would have
55
+ * typed to ask for it.
56
+ *
57
+ * The version is joined with `@` rather than `-` for the same reason. A
58
+ * hyphen made `foo-bar-1.0.0` ambiguous by eye: package `foo` at `bar-1.0.0`,
59
+ * or `foo-bar` at `1.0.0`? npm's own syntax has no such problem, because a
60
+ * package name may not contain `@` beyond its scope.
61
+ *
62
+ * **The scope's `@` stays**, and it is the difference between a correct bundle
63
+ * and a quietly incomplete one.
64
+ *
65
+ * Dropping it — which this did, matching what `npm pack` produces — meant
66
+ * `@something/somewhat` and `something-somewhat` were both
67
+ * `something-somewhat-1.0.0.tgz`. Two different packages, one path: the second
68
+ * landed on the first, and the run reported two artifacts for the one file that
69
+ * survived. A bundle short a package, called complete, found at the far end of
70
+ * an air gap.
71
+ *
72
+ * Keeping the `@` makes that impossible by construction rather than by a check,
73
+ * because an unscoped npm name may not begin with one. It is also the more
74
+ * honest name: the scope is part of what the package *is*, and a directory of
75
+ * archives that has forgotten which ones were scoped is a directory somebody
76
+ * has to cross-reference against a manifest.
77
+ *
78
+ * It does not make every collision impossible — `@a/b-c` and `@a-b/c` still
79
+ * meet — so `assertDistinctOutputs` still runs. That pair is pathological; the
80
+ * scoped-against-unscoped one is `@babel/core` beside `babel-core`, which is a
81
+ * real migration somebody could be bundling.
82
+ *
83
+ * The leading `@` is safe here in a way it is not inside the archive: tar's
84
+ * splice convention applies to the *file list* it is given, which is what
85
+ * `archive.ts` guards with `./`. This is the output path, not an entry.
86
+ */
87
+ export const perSpecArchiveName = (name: string, version: string): string => {
88
+ const { scope, bare } = splitName(name);
89
+ const prefix = scope === undefined ? bare : `${scope}-${bare}`;
90
+ return `${prefix}@${version}.tgz`;
91
+ };
92
+
93
+ /** Name of the archive produced in `single` layout. */
94
+ export const singleArchiveName = (base = "bundle"): string => `${base}.tgz`;
95
+
96
+ /**
97
+ * The import guide written into each bundle.
98
+ *
99
+ * Kept short and copy-pasteable on purpose: whoever opens this is mid-task on a
100
+ * restricted network and does not want prose.
101
+ */
102
+ export const importGuide = (options: {
103
+ readonly packageCount: number;
104
+ readonly createdAt: string;
105
+ readonly toolVersion: string;
106
+ }): string =>
107
+ `# Importing this bundle
108
+
109
+ ${options.packageCount} package tarball(s), laid out exactly as an npm registry serves them:
110
+
111
+ <package-name>/-/<file>.tgz
112
+ @<scope>/<name>/-/<file>.tgz
113
+
114
+ Created ${options.createdAt} by packall ${options.toolVersion}.
115
+ See ${MANIFEST_FILE} for the full list with checksums.
116
+
117
+ ## JFrog Artifactory
118
+
119
+ Unpack, then upload the tree into a **local npm** repository. The layout already
120
+ matches what Artifactory expects, so no path rewriting is needed:
121
+
122
+ tar -xzf <this-bundle>.tgz -C ./bundle
123
+ jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
124
+
125
+ ## Verdaccio / Nexus / any npm registry
126
+
127
+ Publish each tarball individually:
128
+
129
+ find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
130
+
131
+ ## Verifying before import
132
+
133
+ node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
134
+
135
+ Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
136
+ advertised, and each tarball was checked against it at download time.
137
+ `;
@@ -0,0 +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
+
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
+ };