@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
@@ -0,0 +1,328 @@
1
+ /**
2
+ * `package-lock.json` / `npm-shrinkwrap.json`.
3
+ *
4
+ * npm records *ranges* on its edges and relies on the directory nesting to say
5
+ * which copy of a package a range resolved to, so the node resolution walk-up —
6
+ * look beside me, then in my parent, then my grandparent — is reimplemented
7
+ * here.
8
+ */
9
+ import { EdgeKind, LockedRootKind } from "../enums/edge-kind.js";
10
+ import { LockfileFormat } from "../enums/lockfile-format.js";
11
+ import { isRecord } from "../utils/is-record.js";
12
+ import { optionalPeerNames, parseJsonObject, stringOr, stringRecord } from "./json.js";
13
+ import {
14
+ asExactVersion,
15
+ fieldByRootKind,
16
+ importerKey,
17
+ notCovered,
18
+ TreeBuilder,
19
+ } from "./tree-builder.js";
20
+ import type { LockedEdge, LockedTree } from "./types.js";
21
+
22
+ /** One entry of the v2/v3 `packages` map, after narrowing. */
23
+ type NpmEntry = {
24
+ readonly path: string;
25
+ readonly version: string;
26
+ /** Set when the install name differs from the real one — an `npm:` alias. */
27
+ readonly realName: string;
28
+ readonly dependencies: Readonly<Record<string, string>>;
29
+ readonly optionalDependencies: Readonly<Record<string, string>>;
30
+ readonly peerDependencies: Readonly<Record<string, string>>;
31
+ readonly optionalPeers: ReadonlySet<string>;
32
+ };
33
+
34
+ /** Root blocks in npm's precedence order — the first mention of a name wins. */
35
+ const ROOT_KINDS = [
36
+ LockedRootKind.Prod,
37
+ LockedRootKind.Optional,
38
+ LockedRootKind.Dev,
39
+ LockedRootKind.Peer,
40
+ ] as const;
41
+
42
+ /**
43
+ * The node resolution walk-up, over a flat map of install paths.
44
+ *
45
+ * A package at `node_modules/a/node_modules/b` depending on `c` gets, in order,
46
+ * `node_modules/a/node_modules/b/node_modules/c`, then
47
+ * `node_modules/a/node_modules/c`, then `node_modules/c`. This is what makes a
48
+ * range on an edge unambiguous: the nesting already recorded which copy won.
49
+ */
50
+ const NESTING = "/node_modules/";
51
+
52
+ export const parseNpmLockfile = (
53
+ path: string,
54
+ content: string,
55
+ importer: string | undefined,
56
+ ): LockedTree => {
57
+ const root = parseJsonObject(path, content);
58
+ const version = String(root["lockfileVersion"] ?? "?");
59
+ const builder = new TreeBuilder();
60
+
61
+ // v2 carries both shapes for backwards compatibility; `packages` is the
62
+ // accurate one, so it wins whenever it is there.
63
+ const packages = root["packages"];
64
+ const entries = isRecord(packages)
65
+ ? collectEntries(builder, packages)
66
+ : collectLegacyEntries(builder, root);
67
+
68
+ for (const entry of entries.values()) {
69
+ builder.add({
70
+ name: entry.realName,
71
+ version: entry.version,
72
+ dependencies: edgesOf(builder, entries, entry),
73
+ });
74
+ }
75
+
76
+ collectRoots(builder, entries, isRecord(packages) ? packages : root, path, importer);
77
+
78
+ return builder.finish({ format: LockfileFormat.Npm, lockfileVersion: version, path, importer });
79
+ };
80
+
81
+ /** Narrows the v2/v3 `packages` map, dropping what cannot be bundled. */
82
+ const collectEntries = (
83
+ builder: TreeBuilder,
84
+ packages: Record<string, unknown>,
85
+ ): Map<string, NpmEntry> => {
86
+ const entries = new Map<string, NpmEntry>();
87
+
88
+ for (const [entryPath, raw] of Object.entries(packages)) {
89
+ // The root project itself, and workspace members, are not things to fetch.
90
+ const installName = installNameFromPath(entryPath);
91
+ if (installName === null) continue;
92
+ if (!isRecord(raw)) continue;
93
+
94
+ // A symlinked workspace member. Nothing to fetch, but an edge into it has
95
+ // to be explained rather than counted as a hole in the file.
96
+ if (raw["link"] === true) {
97
+ builder.skip(installName, stringOr(raw["resolved"], "link"), "workspace link");
98
+ continue;
99
+ }
100
+
101
+ const rawVersion = raw["version"];
102
+ if (typeof rawVersion !== "string") {
103
+ builder.warn(
104
+ `skipped ${installName} — its lockfile entry records no version, so there is nothing to pin`,
105
+ );
106
+ continue;
107
+ }
108
+
109
+ const resolvedName = typeof raw["name"] === "string" ? raw["name"] : installName;
110
+ const version = asExactVersion(resolvedName, rawVersion);
111
+ if (version._tag === "Unsupported") {
112
+ builder.skip(resolvedName, rawVersion, version.reason);
113
+ continue;
114
+ }
115
+
116
+ // `resolved` names a git or tarball URL for anything not from a registry,
117
+ // even when `version` looks ordinary.
118
+ const resolvedUrl = raw["resolved"];
119
+ if (typeof resolvedUrl === "string" && !isRegistryUrl(resolvedUrl)) {
120
+ builder.skip(resolvedName, resolvedUrl, "not a registry tarball");
121
+ continue;
122
+ }
123
+
124
+ entries.set(entryPath, {
125
+ path: entryPath,
126
+ version: version.version,
127
+ realName: resolvedName,
128
+ dependencies: stringRecord(raw["dependencies"]),
129
+ optionalDependencies: stringRecord(raw["optionalDependencies"]),
130
+ peerDependencies: stringRecord(raw["peerDependencies"]),
131
+ optionalPeers: optionalPeerNames(raw["peerDependenciesMeta"]),
132
+ });
133
+ }
134
+
135
+ return entries;
136
+ };
137
+
138
+ /**
139
+ * Flattens a v1 lockfile's nested `dependencies` tree into the same
140
+ * path-keyed shape v2 uses.
141
+ *
142
+ * v1 is old — npm 6 — but it is exactly the vintage still pinned inside a lot
143
+ * of the locked-down environments this tool exists for, so it is worth the
144
+ * thirty lines rather than an error telling somebody to upgrade npm.
145
+ */
146
+ const collectLegacyEntries = (
147
+ builder: TreeBuilder,
148
+ root: Record<string, unknown>,
149
+ ): Map<string, NpmEntry> => {
150
+ const entries = new Map<string, NpmEntry>();
151
+
152
+ const visit = (tree: unknown, prefix: string): void => {
153
+ if (!isRecord(tree)) return;
154
+ for (const [name, raw] of Object.entries(tree)) {
155
+ if (!isRecord(raw)) continue;
156
+ const entryPath =
157
+ prefix === "" ? `node_modules/${name}` : `${prefix}/node_modules/${name}`;
158
+
159
+ const rawVersion = raw["version"];
160
+ if (typeof rawVersion === "string") {
161
+ const version = asExactVersion(name, rawVersion);
162
+ if (version._tag === "Unsupported") {
163
+ builder.skip(name, rawVersion, version.reason);
164
+ } else {
165
+ entries.set(entryPath, {
166
+ path: entryPath,
167
+ version: version.version,
168
+ realName: name,
169
+ // v1 calls the edge set `requires`.
170
+ dependencies: stringRecord(raw["requires"]),
171
+ optionalDependencies: {},
172
+ peerDependencies: {},
173
+ optionalPeers: new Set(),
174
+ });
175
+ }
176
+ }
177
+
178
+ visit(raw["dependencies"], entryPath);
179
+ }
180
+ };
181
+
182
+ visit(root["dependencies"], "");
183
+ return entries;
184
+ };
185
+
186
+ /** Turns one entry's ranges into edges, resolved through the directory nesting. */
187
+ const edgesOf = (
188
+ builder: TreeBuilder,
189
+ entries: ReadonlyMap<string, NpmEntry>,
190
+ entry: NpmEntry,
191
+ ): ReadonlyArray<LockedEdge> => {
192
+ const edges: Array<LockedEdge> = [];
193
+
194
+ const add = (names: ReadonlyArray<string>, kind: EdgeKind): void => {
195
+ for (const name of names) {
196
+ const targetPath = resolveNestedEdge(entries, entry.path, name);
197
+ const target = targetPath === null ? undefined : entries.get(targetPath);
198
+ if (target === undefined) {
199
+ // Only required edges are worth raising: an optional or peer edge that
200
+ // resolves to nothing is how a lockfile records "not installed here",
201
+ // which npm itself tolerates.
202
+ if (kind === EdgeKind.Prod)
203
+ builder.dangling(`${entry.realName}@${entry.version}`, name);
204
+ continue;
205
+ }
206
+ edges.push({ name: target.realName, version: target.version, kind });
207
+ }
208
+ };
209
+
210
+ const optionalNames = Object.keys(entry.optionalDependencies);
211
+ const optional = new Set(optionalNames);
212
+
213
+ // An entry listed in both is optional: that is how npm records "wanted, but
214
+ // the install may proceed without it".
215
+ add(
216
+ Object.keys(entry.dependencies).filter((name) => !optional.has(name)),
217
+ EdgeKind.Prod,
218
+ );
219
+ add(optionalNames, EdgeKind.Optional);
220
+ add(
221
+ Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)),
222
+ EdgeKind.Peer,
223
+ );
224
+
225
+ return edges;
226
+ };
227
+
228
+ /**
229
+ * Reads the root project's direct dependencies.
230
+ *
231
+ * The lockfile records these under `packages[""]` for v2/v3. v1 has no such
232
+ * entry, so every top-level `node_modules/x` is treated as direct — which
233
+ * over-counts roots slightly on v1 and is the best that file can support.
234
+ */
235
+ const collectRoots = (
236
+ builder: TreeBuilder,
237
+ entries: ReadonlyMap<string, NpmEntry>,
238
+ source: Record<string, unknown>,
239
+ path: string,
240
+ importer: string | undefined,
241
+ ): void => {
242
+ // Resolution starts from the member's own directory, so a dependency it
243
+ // nests privately wins over the hoisted copy — the same rule node applies,
244
+ // which is why the walk-up is shared with ordinary edges.
245
+ const collectFrom = (from: string, project: Record<string, unknown>): void => {
246
+ for (const kind of ROOT_KINDS) {
247
+ const field = fieldByRootKind[kind];
248
+ for (const [name, specifier] of Object.entries(stringRecord(project[field]))) {
249
+ const targetPath = resolveNestedEdge(entries, from, name);
250
+ const target = targetPath === null ? undefined : entries.get(targetPath);
251
+ if (target === undefined) continue;
252
+ builder.addRoot({
253
+ name: target.realName,
254
+ version: target.version,
255
+ kind,
256
+ specifier,
257
+ });
258
+ }
259
+ }
260
+ };
261
+
262
+ if (importer !== undefined) {
263
+ const key = importerKey(importer);
264
+ const project = source[key];
265
+ if (!isRecord(project)) notCovered(path, importer, workspacePaths(source));
266
+ else collectFrom(key, project);
267
+ return;
268
+ }
269
+
270
+ // No member named: every project the file records contributes, which is what
271
+ // pointing --file straight at a monorepo's lockfile should mean.
272
+ const projects = Object.entries(source).flatMap(([key, value]) =>
273
+ isRecord(value) && !key.includes("node_modules/") ? [[key, value] as const] : [],
274
+ );
275
+
276
+ if (projects.length > 0) {
277
+ for (const [key, project] of projects) collectFrom(key, project);
278
+ return;
279
+ }
280
+
281
+ // v1 records no project entry at all, so every top-level install is direct.
282
+ for (const entry of entries.values()) {
283
+ if (entry.path.includes(NESTING)) continue;
284
+ builder.addRoot({
285
+ name: entry.realName,
286
+ version: entry.version,
287
+ kind: LockedRootKind.Prod,
288
+ });
289
+ }
290
+ };
291
+
292
+ /** The workspace paths a v2/v3 lockfile records, for a "does not cover" message. */
293
+ const workspacePaths = (source: Record<string, unknown>): ReadonlyArray<string> =>
294
+ Object.keys(source).flatMap((key) =>
295
+ key === "" ? "." : key.includes("node_modules/") ? [] : key,
296
+ );
297
+
298
+ /**
299
+ * npm records non-registry sources as a URL in `resolved`. Registry tarballs
300
+ * are ordinary http(s); anything with a `git+`, `file:` or bare path form is
301
+ * something we cannot fetch.
302
+ */
303
+ const isRegistryUrl = (url: string): boolean =>
304
+ url.startsWith("http://") || url.startsWith("https://");
305
+
306
+ const resolveNestedEdge = (
307
+ entries: ReadonlyMap<string, NpmEntry>,
308
+ fromPath: string,
309
+ name: string,
310
+ ): string | null => {
311
+ let scope = fromPath;
312
+ for (;;) {
313
+ const candidate = scope === "" ? `node_modules/${name}` : `${scope}${NESTING}${name}`;
314
+ if (entries.has(candidate)) return candidate;
315
+ if (scope === "") return null;
316
+ const at = scope.lastIndexOf(NESTING);
317
+ scope = at === -1 ? "" : scope.slice(0, at);
318
+ }
319
+ };
320
+
321
+ /** `node_modules/@babel/core` -> `@babel/core`; a workspace path -> `null`. */
322
+ const installNameFromPath = (entryPath: string): string | null => {
323
+ const marker = "node_modules/";
324
+ const at = entryPath.lastIndexOf(marker);
325
+ if (at === -1) return null;
326
+ const name = entryPath.slice(at + marker.length);
327
+ return name.length === 0 ? null : name;
328
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Lockfile parsing — npm, pnpm and bun.
3
+ *
4
+ * `--file package.json` reads *ranges*, and a range resolves to whatever is
5
+ * newest at the moment the command runs. That is the right answer for "bundle
6
+ * me something installable" and the wrong one for "bundle exactly what CI
7
+ * installed" — which is what a lockfile records and what this module reads.
8
+ *
9
+ * Three formats, one output shape. Each parser's real job is the same: turn a
10
+ * manager-specific encoding of "package X at path P depends on Y" into a flat
11
+ * graph keyed by `name@version`, with every edge already pointing at a concrete
12
+ * version. Once that is done, nothing downstream has to know which manager
13
+ * wrote the file.
14
+ */
15
+ import { LockfileFormat } from "../enums/lockfile-format.js";
16
+ import { LockfileError } from "../errors.js";
17
+ import { parseBunLockfile } from "./bun.js";
18
+ import { detectLockfile } from "./detect.js";
19
+ import { parseNpmLockfile } from "./npm.js";
20
+ import { parsePnpmLockfile } from "./pnpm.js";
21
+ import type { LockedTree, LockfileParseOptions } from "./types.js";
22
+
23
+ /**
24
+ * Parses lockfile content into a flat graph.
25
+ *
26
+ * Throws `LockfileError`; the caller is expected to be inside an `Effect.try`.
27
+ */
28
+ export const parseLockfile = (
29
+ path: string,
30
+ content: string,
31
+ format?: LockfileFormat,
32
+ options: LockfileParseOptions = {},
33
+ ): LockedTree => {
34
+ const resolved = format ?? resolveFormat(path, content);
35
+ const importer = options.importer;
36
+ switch (resolved) {
37
+ case LockfileFormat.Npm:
38
+ return parseNpmLockfile(path, content, importer);
39
+ case LockfileFormat.Pnpm:
40
+ return parsePnpmLockfile(path, content, importer);
41
+ case LockfileFormat.Bun:
42
+ return parseBunLockfile(path, content, importer);
43
+ }
44
+ };
45
+
46
+ const resolveFormat = (path: string, content: string): LockfileFormat => {
47
+ const detected = detectLockfile(content);
48
+ if (detected._tag === "Supported") return detected.format;
49
+ if (detected._tag === "Unsupported") {
50
+ throw new LockfileError(path, `${detected.label} — ${detected.hint}`);
51
+ }
52
+ throw new LockfileError(
53
+ path,
54
+ "content does not look like a package-lock.json, pnpm-lock.yaml or bun.lock",
55
+ );
56
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `pnpm-lock.yaml`.
3
+ *
4
+ * The easy one: pnpm's snapshots already name exact versions, so there is no
5
+ * nesting walk-up to reimplement. The fiddly parts are the three package-key
6
+ * encodings still in the wild and the parenthesised peer-resolution suffix.
7
+ */
8
+ import semver from "semver";
9
+ import { parse as parseYaml } from "yaml";
10
+
11
+ import { parseDependencyTarget } from "../dependency-range.js";
12
+ import { EdgeKind, LockedRootKind } from "../enums/edge-kind.js";
13
+ import { LockfileFormat } from "../enums/lockfile-format.js";
14
+ import { LockfileError } from "../errors.js";
15
+ import { isRecord } from "../utils/is-record.js";
16
+ import { describeError } from "./json.js";
17
+ import { fieldByRootKind, notCovered, TreeBuilder } from "./tree-builder.js";
18
+ import type { LockedEdge, LockedTree } from "./types.js";
19
+
20
+ /** `name@version`, as a snapshot or importer entry resolves to. */
21
+ type PnpmReference = {
22
+ readonly name: string;
23
+ readonly version: string;
24
+ };
25
+
26
+ /** pnpm never records peer dependencies against an importer. */
27
+ const ROOT_KINDS = [LockedRootKind.Prod, LockedRootKind.Optional, LockedRootKind.Dev] as const;
28
+
29
+ export const parsePnpmLockfile = (
30
+ path: string,
31
+ content: string,
32
+ importer: string | undefined,
33
+ ): LockedTree => {
34
+ let root: unknown;
35
+ try {
36
+ root = parseYaml(content);
37
+ } catch (error) {
38
+ throw new LockfileError(path, `not valid YAML — ${describeError(error)}`, { cause: error });
39
+ }
40
+ if (!isRecord(root)) {
41
+ throw new LockfileError(path, "top level is not a mapping");
42
+ }
43
+
44
+ const version = String(root["lockfileVersion"] ?? "?");
45
+ const builder = new TreeBuilder();
46
+
47
+ // v9 split the graph in two: `packages` keeps resolution metadata, and
48
+ // `snapshots` keeps the edges. Before that both lived in `packages`.
49
+ const packages = isRecord(root["packages"]) ? root["packages"] : {};
50
+ const snapshots = isRecord(root["snapshots"]) ? root["snapshots"] : packages;
51
+
52
+ const known = new Map<string, PnpmReference>();
53
+ for (const key of Object.keys(packages)) {
54
+ const parsed = parsePnpmKey(key);
55
+ if (parsed === null) continue;
56
+ known.set(key, parsed);
57
+ }
58
+
59
+ for (const [key, raw] of Object.entries(snapshots)) {
60
+ const parsed = parsePnpmKey(key) ?? known.get(key);
61
+ if (parsed === undefined || parsed === null) {
62
+ const described = describeKey(key);
63
+ if (described !== null) builder.skip(described.name, described.spec, described.reason);
64
+ continue;
65
+ }
66
+ builder.add({
67
+ name: parsed.name,
68
+ version: parsed.version,
69
+ dependencies: edgesOf(builder, parsed, isRecord(raw) ? raw : {}),
70
+ });
71
+ }
72
+
73
+ collectRoots(builder, root, path, importer);
74
+
75
+ return builder.finish({
76
+ format: LockfileFormat.Pnpm,
77
+ lockfileVersion: version,
78
+ path,
79
+ importer,
80
+ });
81
+ };
82
+
83
+ const edgesOf = (
84
+ builder: TreeBuilder,
85
+ from: PnpmReference,
86
+ snapshot: Record<string, unknown>,
87
+ ): ReadonlyArray<LockedEdge> => {
88
+ const edges: Array<LockedEdge> = [];
89
+
90
+ const add = (field: string, kind: EdgeKind): void => {
91
+ const entries = snapshot[field];
92
+ if (!isRecord(entries)) return;
93
+ for (const [name, raw] of Object.entries(entries)) {
94
+ if (typeof raw !== "string") continue;
95
+ const target = parsePnpmReference(name, raw);
96
+ if (target === null) {
97
+ if (kind === EdgeKind.Prod) builder.dangling(`${from.name}@${from.version}`, name);
98
+ continue;
99
+ }
100
+ edges.push({ ...target, kind });
101
+ }
102
+ };
103
+
104
+ add(fieldByRootKind[LockedRootKind.Prod], EdgeKind.Prod);
105
+ add(fieldByRootKind[LockedRootKind.Optional], EdgeKind.Optional);
106
+ add(fieldByRootKind[LockedRootKind.Peer], EdgeKind.Peer);
107
+
108
+ return edges;
109
+ };
110
+
111
+ /**
112
+ * The importers' direct dependencies become the roots.
113
+ *
114
+ * pnpm is the format that makes this easy: `importers` is already keyed by the
115
+ * path each package.json sits at, so scoping to one member is a lookup rather
116
+ * than a reconstruction.
117
+ */
118
+ const collectRoots = (
119
+ builder: TreeBuilder,
120
+ root: Record<string, unknown>,
121
+ path: string,
122
+ member: string | undefined,
123
+ ): void => {
124
+ // A single-project lockfile has no `importers` block; its dependencies sit at
125
+ // the top level, which is the same thing as an importer called ".".
126
+ const importers = isRecord(root["importers"]) ? root["importers"] : { ".": root };
127
+
128
+ const selected =
129
+ member === undefined
130
+ ? Object.values(importers)
131
+ : Object.hasOwn(importers, member)
132
+ ? [importers[member]]
133
+ : notCovered(path, member, Object.keys(importers));
134
+
135
+ for (const importer of selected) {
136
+ if (!isRecord(importer)) continue;
137
+ for (const kind of ROOT_KINDS) {
138
+ const entries = importer[fieldByRootKind[kind]];
139
+ if (!isRecord(entries)) continue;
140
+
141
+ for (const [name, raw] of Object.entries(entries)) {
142
+ // v5 wrote a bare version string; v6 and v9 write
143
+ // `{specifier, version}`.
144
+ const value =
145
+ typeof raw === "string" ? raw : isRecord(raw) ? raw["version"] : undefined;
146
+ if (typeof value !== "string") continue;
147
+
148
+ const target = parsePnpmReference(name, value);
149
+ if (target === null) continue;
150
+
151
+ const specifier =
152
+ isRecord(raw) && typeof raw["specifier"] === "string"
153
+ ? raw["specifier"]
154
+ : undefined;
155
+ builder.addRoot({ ...target, kind, specifier });
156
+ }
157
+ }
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Reads a dependency value from a snapshot or importer.
163
+ *
164
+ * The value is either a bare version (`1.4.0`) or a full package key
165
+ * (`/lodash@4.17.21`, used when the install name is an alias of another
166
+ * package). Both may carry a peer-resolution suffix in parentheses.
167
+ */
168
+ const parsePnpmReference = (name: string, raw: string): PnpmReference | null => {
169
+ const value = stripSuffix(raw);
170
+
171
+ const exact = semver.valid(value, { loose: true });
172
+ if (exact !== null) return { name, version: exact };
173
+
174
+ return parsePnpmKey(raw);
175
+ };
176
+
177
+ /**
178
+ * Splits a package key into name and version.
179
+ *
180
+ * Three encodings across the versions still in the wild:
181
+ * v5 `/@babel/core/7.0.0`
182
+ * v6 `/@babel/core@7.0.0`
183
+ * v9 `@babel/core@7.0.0`
184
+ *
185
+ * plus an optional `(peer@1.0.0)` or `(patch_hash=…)` suffix on any of them.
186
+ */
187
+ export const parsePnpmKey = (key: string): PnpmReference | null => {
188
+ const body = stripSuffix(key.startsWith("/") ? key.slice(1) : key);
189
+
190
+ // A scoped name's own `@` sits at index 0, so only a later one separates the
191
+ // version. When there is none, this is the v5 form and the last `/` does it.
192
+ const at = body.lastIndexOf("@");
193
+ const cut = at > 0 ? at : body.lastIndexOf("/");
194
+ if (cut <= 0) return null;
195
+
196
+ const name = body.slice(0, cut);
197
+ const version = semver.valid(body.slice(cut + 1), { loose: true });
198
+ if (version === null || name.length === 0) return null;
199
+
200
+ return { name, version };
201
+ };
202
+
203
+ /** Describes a key we cannot pin, so the skip warning can say why. */
204
+ const describeKey = (
205
+ key: string,
206
+ ): { readonly name: string; readonly spec: string; readonly reason: string } | null => {
207
+ const body = key.startsWith("/") ? key.slice(1) : key;
208
+ const at = body.lastIndexOf("@");
209
+ if (at <= 0) return null;
210
+ const name = body.slice(0, at);
211
+ const spec = body.slice(at + 1);
212
+ const target = parseDependencyTarget(name, spec);
213
+ return {
214
+ name,
215
+ spec,
216
+ reason: target._tag === "Unsupported" ? target.reason : "not an exact version",
217
+ };
218
+ };
219
+
220
+ /**
221
+ * Drops the parenthesised peer-resolution suffix.
222
+ *
223
+ * pnpm distinguishes the same version installed against different peers —
224
+ * `react-dom@18.3.1(react@18.3.1)`. Those are the same published tarball, which
225
+ * is all this tool downloads, so the suffix is noise here.
226
+ */
227
+ const stripSuffix = (value: string): string => {
228
+ const open = value.indexOf("(");
229
+ return open === -1 ? value : value.slice(0, open);
230
+ };