@packall/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-DmF9XNl7.js +284 -0
  4. package/dist/archive-DmF9XNl7.js.map +1 -0
  5. package/dist/archive.d.ts +47 -15
  6. package/dist/archive.d.ts.map +1 -1
  7. package/dist/bundle.d.ts +6 -3
  8. package/dist/bundle.d.ts.map +1 -1
  9. package/dist/dependency-range.d.ts.map +1 -1
  10. package/dist/download.d.ts +2 -1
  11. package/dist/download.d.ts.map +1 -1
  12. package/dist/enums/artifact-kind.d.ts.map +1 -1
  13. package/dist/enums/edge-kind.d.ts.map +1 -1
  14. package/dist/enums/input-file-kind.d.ts.map +1 -1
  15. package/dist/enums/layout.d.ts.map +1 -1
  16. package/dist/enums/lockfile-format.d.ts.map +1 -1
  17. package/dist/enums/phase.d.ts.map +1 -1
  18. package/dist/errors.d.ts +11 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/index.d.ts +12 -10
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +169 -344
  23. package/dist/index.js.map +1 -1
  24. package/dist/input-file.d.ts.map +1 -1
  25. package/dist/integrity.d.ts +37 -5
  26. package/dist/integrity.d.ts.map +1 -1
  27. package/dist/layout.d.ts +41 -7
  28. package/dist/layout.d.ts.map +1 -1
  29. package/dist/locked-resolve.d.ts.map +1 -1
  30. package/dist/lockfile/bun.d.ts.map +1 -1
  31. package/dist/lockfile/detect.d.ts.map +1 -1
  32. package/dist/lockfile/json.d.ts.map +1 -1
  33. package/dist/lockfile/names.d.ts.map +1 -1
  34. package/dist/lockfile/npm.d.ts.map +1 -1
  35. package/dist/lockfile/parse.d.ts.map +1 -1
  36. package/dist/lockfile/pnpm.d.ts.map +1 -1
  37. package/dist/lockfile/tree-builder.d.ts.map +1 -1
  38. package/dist/lockfile/types.d.ts.map +1 -1
  39. package/dist/manifest.d.ts.map +1 -1
  40. package/dist/node/archiver.d.ts +5 -0
  41. package/dist/node/archiver.d.ts.map +1 -0
  42. package/dist/node/index.d.ts +13 -0
  43. package/dist/node/index.d.ts.map +1 -0
  44. package/dist/node/index.js +55 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/options.d.ts +3 -3
  47. package/dist/options.d.ts.map +1 -1
  48. package/dist/platform.d.ts +5 -10
  49. package/dist/platform.d.ts.map +1 -1
  50. package/dist/progress.d.ts.map +1 -1
  51. package/dist/registry.d.ts.map +1 -1
  52. package/dist/resolve.d.ts.map +1 -1
  53. package/dist/schemas/lenient.d.ts.map +1 -1
  54. package/dist/schemas/package-json.d.ts.map +1 -1
  55. package/dist/spec.d.ts.map +1 -1
  56. package/dist/types/value-of.d.ts.map +1 -1
  57. package/dist/utils/is-record.d.ts.map +1 -1
  58. package/package.json +11 -10
  59. package/src/archive.ts +95 -92
  60. package/src/bundle.ts +736 -616
  61. package/src/dependency-range.ts +121 -115
  62. package/src/download.ts +155 -133
  63. package/src/enums/artifact-kind.ts +5 -5
  64. package/src/enums/edge-kind.ts +13 -13
  65. package/src/enums/input-file-kind.ts +9 -9
  66. package/src/enums/layout.ts +20 -20
  67. package/src/enums/lockfile-format.ts +6 -6
  68. package/src/enums/phase.ts +8 -8
  69. package/src/errors.ts +211 -199
  70. package/src/index.ts +126 -113
  71. package/src/input-file.ts +246 -234
  72. package/src/integrity.ts +165 -121
  73. package/src/layout.ts +63 -29
  74. package/src/locked-resolve.ts +471 -461
  75. package/src/lockfile/bun.ts +214 -207
  76. package/src/lockfile/detect.ts +80 -80
  77. package/src/lockfile/json.ts +91 -92
  78. package/src/lockfile/names.ts +9 -9
  79. package/src/lockfile/npm.ts +328 -308
  80. package/src/lockfile/parse.ts +32 -32
  81. package/src/lockfile/pnpm.ts +230 -225
  82. package/src/lockfile/tree-builder.ts +116 -116
  83. package/src/lockfile/types.ts +52 -52
  84. package/src/manifest.ts +129 -133
  85. package/src/node/archiver.ts +85 -0
  86. package/src/node/index.ts +13 -0
  87. package/src/options.ts +84 -84
  88. package/src/platform.ts +97 -101
  89. package/src/progress.ts +105 -106
  90. package/src/registry.ts +116 -115
  91. package/src/resolve.ts +560 -537
  92. package/src/schemas/lenient.ts +67 -60
  93. package/src/schemas/package-json.ts +11 -10
  94. package/src/spec.ts +123 -119
  95. package/src/types/value-of.ts +1 -1
  96. package/src/utils/is-record.ts +1 -1
@@ -12,13 +12,13 @@
12
12
  * version. Once that is done, nothing downstream has to know which manager
13
13
  * wrote the file.
14
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"
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
22
 
23
23
  /**
24
24
  * Parses lockfile content into a flat graph.
@@ -26,31 +26,31 @@ import type { LockedTree, LockfileParseOptions } from "./types.js"
26
26
  * Throws `LockfileError`; the caller is expected to be inside an `Effect.try`.
27
27
  */
28
28
  export const parseLockfile = (
29
- path: string,
30
- content: string,
31
- format?: LockfileFormat,
32
- options: LockfileParseOptions = {}
29
+ path: string,
30
+ content: string,
31
+ format?: LockfileFormat,
32
+ options: LockfileParseOptions = {},
33
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
- }
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
45
 
46
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
- }
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
+ };
@@ -1,225 +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
- import { parseDependencyTarget } from "../dependency-range.js"
11
- import { EdgeKind, LockedRootKind } from "../enums/edge-kind.js"
12
- import { LockfileFormat } from "../enums/lockfile-format.js"
13
- import { LockfileError } from "../errors.js"
14
- import { describeError } from "./json.js"
15
- import { isRecord } from "../utils/is-record.js"
16
- import { fieldByRootKind, notCovered, TreeBuilder } from "./tree-builder.js"
17
- import type { LockedEdge, LockedTree } from "./types.js"
18
-
19
- /** `name@version`, as a snapshot or importer entry resolves to. */
20
- type PnpmReference = {
21
- readonly name: string
22
- readonly version: string
23
- }
24
-
25
- /** pnpm never records peer dependencies against an importer. */
26
- const ROOT_KINDS = [
27
- LockedRootKind.Prod,
28
- LockedRootKind.Optional,
29
- LockedRootKind.Dev
30
- ] as const
31
-
32
- export const parsePnpmLockfile = (
33
- path: string,
34
- content: string,
35
- importer: string | undefined
36
- ): LockedTree => {
37
- let root: unknown
38
- try {
39
- root = parseYaml(content)
40
- } catch (error) {
41
- throw new LockfileError(path, `not valid YAML ${describeError(error)}`, { cause: error })
42
- }
43
- if (!isRecord(root)) {
44
- throw new LockfileError(path, "top level is not a mapping")
45
- }
46
-
47
- const version = String(root["lockfileVersion"] ?? "?")
48
- const builder = new TreeBuilder()
49
-
50
- // v9 split the graph in two: `packages` keeps resolution metadata, and
51
- // `snapshots` keeps the edges. Before that both lived in `packages`.
52
- const packages = isRecord(root["packages"]) ? root["packages"] : {}
53
- const snapshots = isRecord(root["snapshots"]) ? root["snapshots"] : packages
54
-
55
- const known = new Map<string, PnpmReference>()
56
- for (const key of Object.keys(packages)) {
57
- const parsed = parsePnpmKey(key)
58
- if (parsed === null) continue
59
- known.set(key, parsed)
60
- }
61
-
62
- for (const [key, raw] of Object.entries(snapshots)) {
63
- const parsed = parsePnpmKey(key) ?? known.get(key)
64
- if (parsed === undefined || parsed === null) {
65
- const described = describeKey(key)
66
- if (described !== null) builder.skip(described.name, described.spec, described.reason)
67
- continue
68
- }
69
- builder.add({
70
- name: parsed.name,
71
- version: parsed.version,
72
- dependencies: edgesOf(builder, parsed, isRecord(raw) ? raw : {})
73
- })
74
- }
75
-
76
- collectRoots(builder, root, path, importer)
77
-
78
- return builder.finish({ format: LockfileFormat.Pnpm, lockfileVersion: version, path, importer })
79
- }
80
-
81
- const edgesOf = (
82
- builder: TreeBuilder,
83
- from: PnpmReference,
84
- snapshot: Record<string, unknown>
85
- ): ReadonlyArray<LockedEdge> => {
86
- const edges: Array<LockedEdge> = []
87
-
88
- const add = (field: string, kind: EdgeKind): void => {
89
- const entries = snapshot[field]
90
- if (!isRecord(entries)) return
91
- for (const [name, raw] of Object.entries(entries)) {
92
- if (typeof raw !== "string") continue
93
- const target = parsePnpmReference(name, raw)
94
- if (target === null) {
95
- if (kind === EdgeKind.Prod) builder.dangling(`${from.name}@${from.version}`, name)
96
- continue
97
- }
98
- edges.push({ ...target, kind })
99
- }
100
- }
101
-
102
- add(fieldByRootKind[LockedRootKind.Prod], EdgeKind.Prod)
103
- add(fieldByRootKind[LockedRootKind.Optional], EdgeKind.Optional)
104
- add(fieldByRootKind[LockedRootKind.Peer], EdgeKind.Peer)
105
-
106
- return edges
107
- }
108
-
109
- /**
110
- * The importers' direct dependencies become the roots.
111
- *
112
- * pnpm is the format that makes this easy: `importers` is already keyed by the
113
- * path each package.json sits at, so scoping to one member is a lookup rather
114
- * than a reconstruction.
115
- */
116
- const collectRoots = (
117
- builder: TreeBuilder,
118
- root: Record<string, unknown>,
119
- path: string,
120
- member: string | undefined
121
- ): void => {
122
- // A single-project lockfile has no `importers` block; its dependencies sit at
123
- // the top level, which is the same thing as an importer called ".".
124
- const importers = isRecord(root["importers"]) ? root["importers"] : { ".": root }
125
-
126
- const selected =
127
- member === undefined
128
- ? Object.values(importers)
129
- : Object.hasOwn(importers, member)
130
- ? [importers[member]]
131
- : notCovered(path, member, Object.keys(importers))
132
-
133
- for (const importer of selected) {
134
- if (!isRecord(importer)) continue
135
- for (const kind of ROOT_KINDS) {
136
- const entries = importer[fieldByRootKind[kind]]
137
- if (!isRecord(entries)) continue
138
-
139
- for (const [name, raw] of Object.entries(entries)) {
140
- // v5 wrote a bare version string; v6 and v9 write
141
- // `{specifier, version}`.
142
- const value = typeof raw === "string" ? raw : isRecord(raw) ? raw["version"] : undefined
143
- if (typeof value !== "string") continue
144
-
145
- const target = parsePnpmReference(name, value)
146
- if (target === null) continue
147
-
148
- const specifier =
149
- isRecord(raw) && typeof raw["specifier"] === "string" ? raw["specifier"] : undefined
150
- builder.addRoot({ ...target, kind, specifier })
151
- }
152
- }
153
- }
154
- }
155
-
156
- /**
157
- * Reads a dependency value from a snapshot or importer.
158
- *
159
- * The value is either a bare version (`1.4.0`) or a full package key
160
- * (`/lodash@4.17.21`, used when the install name is an alias of another
161
- * package). Both may carry a peer-resolution suffix in parentheses.
162
- */
163
- const parsePnpmReference = (name: string, raw: string): PnpmReference | null => {
164
- const value = stripSuffix(raw)
165
-
166
- const exact = semver.valid(value, { loose: true })
167
- if (exact !== null) return { name, version: exact }
168
-
169
- return parsePnpmKey(raw)
170
- }
171
-
172
- /**
173
- * Splits a package key into name and version.
174
- *
175
- * Three encodings across the versions still in the wild:
176
- * v5 `/@babel/core/7.0.0`
177
- * v6 `/@babel/core@7.0.0`
178
- * v9 `@babel/core@7.0.0`
179
- *
180
- * plus an optional `(peer@1.0.0)` or `(patch_hash=…)` suffix on any of them.
181
- */
182
- export const parsePnpmKey = (key: string): PnpmReference | null => {
183
- const body = stripSuffix(key.startsWith("/") ? key.slice(1) : key)
184
-
185
- // A scoped name's own `@` sits at index 0, so only a later one separates the
186
- // version. When there is none, this is the v5 form and the last `/` does it.
187
- const at = body.lastIndexOf("@")
188
- const cut = at > 0 ? at : body.lastIndexOf("/")
189
- if (cut <= 0) return null
190
-
191
- const name = body.slice(0, cut)
192
- const version = semver.valid(body.slice(cut + 1), { loose: true })
193
- if (version === null || name.length === 0) return null
194
-
195
- return { name, version }
196
- }
197
-
198
- /** Describes a key we cannot pin, so the skip warning can say why. */
199
- const describeKey = (
200
- key: string
201
- ): { readonly name: string; readonly spec: string; readonly reason: string } | null => {
202
- const body = key.startsWith("/") ? key.slice(1) : key
203
- const at = body.lastIndexOf("@")
204
- if (at <= 0) return null
205
- const name = body.slice(0, at)
206
- const spec = body.slice(at + 1)
207
- const target = parseDependencyTarget(name, spec)
208
- return {
209
- name,
210
- spec,
211
- reason: target._tag === "Unsupported" ? target.reason : "not an exact version"
212
- }
213
- }
214
-
215
- /**
216
- * Drops the parenthesised peer-resolution suffix.
217
- *
218
- * pnpm distinguishes the same version installed against different peers —
219
- * `react-dom@18.3.1(react@18.3.1)`. Those are the same published tarball, which
220
- * is all this tool downloads, so the suffix is noise here.
221
- */
222
- const stripSuffix = (value: string): string => {
223
- const open = value.indexOf("(")
224
- return open === -1 ? value : value.slice(0, open)
225
- }
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
+ };