@packall/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-DmF9XNl7.js +284 -0
  4. package/dist/archive-DmF9XNl7.js.map +1 -0
  5. package/dist/archive.d.ts +47 -15
  6. package/dist/archive.d.ts.map +1 -1
  7. package/dist/bundle.d.ts +6 -3
  8. package/dist/bundle.d.ts.map +1 -1
  9. package/dist/dependency-range.d.ts.map +1 -1
  10. package/dist/download.d.ts +2 -1
  11. package/dist/download.d.ts.map +1 -1
  12. package/dist/enums/artifact-kind.d.ts.map +1 -1
  13. package/dist/enums/edge-kind.d.ts.map +1 -1
  14. package/dist/enums/input-file-kind.d.ts.map +1 -1
  15. package/dist/enums/layout.d.ts.map +1 -1
  16. package/dist/enums/lockfile-format.d.ts.map +1 -1
  17. package/dist/enums/phase.d.ts.map +1 -1
  18. package/dist/errors.d.ts +11 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/index.d.ts +12 -10
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +169 -344
  23. package/dist/index.js.map +1 -1
  24. package/dist/input-file.d.ts.map +1 -1
  25. package/dist/integrity.d.ts +37 -5
  26. package/dist/integrity.d.ts.map +1 -1
  27. package/dist/layout.d.ts +41 -7
  28. package/dist/layout.d.ts.map +1 -1
  29. package/dist/locked-resolve.d.ts.map +1 -1
  30. package/dist/lockfile/bun.d.ts.map +1 -1
  31. package/dist/lockfile/detect.d.ts.map +1 -1
  32. package/dist/lockfile/json.d.ts.map +1 -1
  33. package/dist/lockfile/names.d.ts.map +1 -1
  34. package/dist/lockfile/npm.d.ts.map +1 -1
  35. package/dist/lockfile/parse.d.ts.map +1 -1
  36. package/dist/lockfile/pnpm.d.ts.map +1 -1
  37. package/dist/lockfile/tree-builder.d.ts.map +1 -1
  38. package/dist/lockfile/types.d.ts.map +1 -1
  39. package/dist/manifest.d.ts.map +1 -1
  40. package/dist/node/archiver.d.ts +5 -0
  41. package/dist/node/archiver.d.ts.map +1 -0
  42. package/dist/node/index.d.ts +13 -0
  43. package/dist/node/index.d.ts.map +1 -0
  44. package/dist/node/index.js +55 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/options.d.ts +3 -3
  47. package/dist/options.d.ts.map +1 -1
  48. package/dist/platform.d.ts +5 -10
  49. package/dist/platform.d.ts.map +1 -1
  50. package/dist/progress.d.ts.map +1 -1
  51. package/dist/registry.d.ts.map +1 -1
  52. package/dist/resolve.d.ts.map +1 -1
  53. package/dist/schemas/lenient.d.ts.map +1 -1
  54. package/dist/schemas/package-json.d.ts.map +1 -1
  55. package/dist/spec.d.ts.map +1 -1
  56. package/dist/types/value-of.d.ts.map +1 -1
  57. package/dist/utils/is-record.d.ts.map +1 -1
  58. package/package.json +11 -10
  59. package/src/archive.ts +95 -92
  60. package/src/bundle.ts +736 -616
  61. package/src/dependency-range.ts +121 -115
  62. package/src/download.ts +155 -133
  63. package/src/enums/artifact-kind.ts +5 -5
  64. package/src/enums/edge-kind.ts +13 -13
  65. package/src/enums/input-file-kind.ts +9 -9
  66. package/src/enums/layout.ts +20 -20
  67. package/src/enums/lockfile-format.ts +6 -6
  68. package/src/enums/phase.ts +8 -8
  69. package/src/errors.ts +211 -199
  70. package/src/index.ts +126 -113
  71. package/src/input-file.ts +246 -234
  72. package/src/integrity.ts +165 -121
  73. package/src/layout.ts +63 -29
  74. package/src/locked-resolve.ts +471 -461
  75. package/src/lockfile/bun.ts +214 -207
  76. package/src/lockfile/detect.ts +80 -80
  77. package/src/lockfile/json.ts +91 -92
  78. package/src/lockfile/names.ts +9 -9
  79. package/src/lockfile/npm.ts +328 -308
  80. package/src/lockfile/parse.ts +32 -32
  81. package/src/lockfile/pnpm.ts +230 -225
  82. package/src/lockfile/tree-builder.ts +116 -116
  83. package/src/lockfile/types.ts +52 -52
  84. package/src/manifest.ts +129 -133
  85. package/src/node/archiver.ts +85 -0
  86. package/src/node/index.ts +13 -0
  87. package/src/options.ts +84 -84
  88. package/src/platform.ts +97 -101
  89. package/src/progress.ts +105 -106
  90. package/src/registry.ts +116 -115
  91. package/src/resolve.ts +560 -537
  92. package/src/schemas/lenient.ts +67 -60
  93. package/src/schemas/package-json.ts +11 -10
  94. package/src/spec.ts +123 -119
  95. package/src/types/value-of.ts +1 -1
  96. package/src/utils/is-record.ts +1 -1
@@ -1,308 +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 { optionalPeerNames, parseJsonObject, stringOr, 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 entry of the v2/v3 `packages` map, after narrowing. */
17
- type NpmEntry = {
18
- readonly path: string
19
- readonly version: string
20
- /** Set when the install name differs from the real one — an `npm:` alias. */
21
- readonly realName: string
22
- readonly dependencies: Readonly<Record<string, string>>
23
- readonly optionalDependencies: Readonly<Record<string, string>>
24
- readonly peerDependencies: Readonly<Record<string, string>>
25
- readonly optionalPeers: ReadonlySet<string>
26
- }
27
-
28
- /** Root blocks in npm's precedence order — the first mention of a name wins. */
29
- const ROOT_KINDS = [
30
- LockedRootKind.Prod,
31
- LockedRootKind.Optional,
32
- LockedRootKind.Dev,
33
- LockedRootKind.Peer
34
- ] as const
35
-
36
- /**
37
- * The node resolution walk-up, over a flat map of install paths.
38
- *
39
- * A package at `node_modules/a/node_modules/b` depending on `c` gets, in order,
40
- * `node_modules/a/node_modules/b/node_modules/c`, then
41
- * `node_modules/a/node_modules/c`, then `node_modules/c`. This is what makes a
42
- * range on an edge unambiguous: the nesting already recorded which copy won.
43
- */
44
- const NESTING = "/node_modules/"
45
-
46
- export const parseNpmLockfile = (
47
- path: string,
48
- content: string,
49
- importer: string | undefined
50
- ): LockedTree => {
51
- const root = parseJsonObject(path, content)
52
- const version = String(root["lockfileVersion"] ?? "?")
53
- const builder = new TreeBuilder()
54
-
55
- // v2 carries both shapes for backwards compatibility; `packages` is the
56
- // accurate one, so it wins whenever it is there.
57
- const packages = root["packages"]
58
- const entries = isRecord(packages)
59
- ? collectEntries(builder, packages)
60
- : collectLegacyEntries(builder, root)
61
-
62
- for (const entry of entries.values()) {
63
- builder.add({
64
- name: entry.realName,
65
- version: entry.version,
66
- dependencies: edgesOf(builder, entries, entry)
67
- })
68
- }
69
-
70
- collectRoots(builder, entries, isRecord(packages) ? packages : root, path, importer)
71
-
72
- return builder.finish({ format: LockfileFormat.Npm, lockfileVersion: version, path, importer })
73
- }
74
-
75
- /** Narrows the v2/v3 `packages` map, dropping what cannot be bundled. */
76
- const collectEntries = (
77
- builder: TreeBuilder,
78
- packages: Record<string, unknown>
79
- ): Map<string, NpmEntry> => {
80
- const entries = new Map<string, NpmEntry>()
81
-
82
- for (const [entryPath, raw] of Object.entries(packages)) {
83
- // The root project itself, and workspace members, are not things to fetch.
84
- const installName = installNameFromPath(entryPath)
85
- if (installName === null) continue
86
- if (!isRecord(raw)) continue
87
-
88
- // A symlinked workspace member. Nothing to fetch, but an edge into it has
89
- // to be explained rather than counted as a hole in the file.
90
- if (raw["link"] === true) {
91
- builder.skip(installName, stringOr(raw["resolved"], "link"), "workspace link")
92
- continue
93
- }
94
-
95
- const rawVersion = raw["version"]
96
- if (typeof rawVersion !== "string") {
97
- builder.warn(
98
- `skipped ${installName} — its lockfile entry records no version, so there is nothing to pin`
99
- )
100
- continue
101
- }
102
-
103
- const resolvedName = typeof raw["name"] === "string" ? raw["name"] : installName
104
- const version = asExactVersion(resolvedName, rawVersion)
105
- if (version._tag === "Unsupported") {
106
- builder.skip(resolvedName, rawVersion, version.reason)
107
- continue
108
- }
109
-
110
- // `resolved` names a git or tarball URL for anything not from a registry,
111
- // even when `version` looks ordinary.
112
- const resolvedUrl = raw["resolved"]
113
- if (typeof resolvedUrl === "string" && !isRegistryUrl(resolvedUrl)) {
114
- builder.skip(resolvedName, resolvedUrl, "not a registry tarball")
115
- continue
116
- }
117
-
118
- entries.set(entryPath, {
119
- path: entryPath,
120
- version: version.version,
121
- realName: resolvedName,
122
- dependencies: stringRecord(raw["dependencies"]),
123
- optionalDependencies: stringRecord(raw["optionalDependencies"]),
124
- peerDependencies: stringRecord(raw["peerDependencies"]),
125
- optionalPeers: optionalPeerNames(raw["peerDependenciesMeta"])
126
- })
127
- }
128
-
129
- return entries
130
- }
131
-
132
- /**
133
- * Flattens a v1 lockfile's nested `dependencies` tree into the same
134
- * path-keyed shape v2 uses.
135
- *
136
- * v1 is old — npm 6 — but it is exactly the vintage still pinned inside a lot
137
- * of the locked-down environments this tool exists for, so it is worth the
138
- * thirty lines rather than an error telling somebody to upgrade npm.
139
- */
140
- const collectLegacyEntries = (
141
- builder: TreeBuilder,
142
- root: Record<string, unknown>
143
- ): Map<string, NpmEntry> => {
144
- const entries = new Map<string, NpmEntry>()
145
-
146
- const visit = (tree: unknown, prefix: string): void => {
147
- if (!isRecord(tree)) return
148
- for (const [name, raw] of Object.entries(tree)) {
149
- if (!isRecord(raw)) continue
150
- const entryPath = prefix === "" ? `node_modules/${name}` : `${prefix}/node_modules/${name}`
151
-
152
- const rawVersion = raw["version"]
153
- if (typeof rawVersion === "string") {
154
- const version = asExactVersion(name, rawVersion)
155
- if (version._tag === "Unsupported") {
156
- builder.skip(name, rawVersion, version.reason)
157
- } else {
158
- entries.set(entryPath, {
159
- path: entryPath,
160
- version: version.version,
161
- realName: name,
162
- // v1 calls the edge set `requires`.
163
- dependencies: stringRecord(raw["requires"]),
164
- optionalDependencies: {},
165
- peerDependencies: {},
166
- optionalPeers: new Set()
167
- })
168
- }
169
- }
170
-
171
- visit(raw["dependencies"], entryPath)
172
- }
173
- }
174
-
175
- visit(root["dependencies"], "")
176
- return entries
177
- }
178
-
179
- /** Turns one entry's ranges into edges, resolved through the directory nesting. */
180
- const edgesOf = (
181
- builder: TreeBuilder,
182
- entries: ReadonlyMap<string, NpmEntry>,
183
- entry: NpmEntry
184
- ): ReadonlyArray<LockedEdge> => {
185
- const edges: Array<LockedEdge> = []
186
-
187
- const add = (names: ReadonlyArray<string>, kind: EdgeKind): void => {
188
- for (const name of names) {
189
- const targetPath = resolveNestedEdge(entries, entry.path, name)
190
- const target = targetPath === null ? undefined : entries.get(targetPath)
191
- if (target === undefined) {
192
- // Only required edges are worth raising: an optional or peer edge that
193
- // resolves to nothing is how a lockfile records "not installed here",
194
- // which npm itself tolerates.
195
- if (kind === EdgeKind.Prod) builder.dangling(`${entry.realName}@${entry.version}`, name)
196
- continue
197
- }
198
- edges.push({ name: target.realName, version: target.version, kind })
199
- }
200
- }
201
-
202
- const optionalNames = Object.keys(entry.optionalDependencies)
203
- const optional = new Set(optionalNames)
204
-
205
- // An entry listed in both is optional: that is how npm records "wanted, but
206
- // the install may proceed without it".
207
- add(Object.keys(entry.dependencies).filter((name) => !optional.has(name)), EdgeKind.Prod)
208
- add(optionalNames, EdgeKind.Optional)
209
- add(
210
- Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)),
211
- EdgeKind.Peer
212
- )
213
-
214
- return edges
215
- }
216
-
217
- /**
218
- * Reads the root project's direct dependencies.
219
- *
220
- * The lockfile records these under `packages[""]` for v2/v3. v1 has no such
221
- * entry, so every top-level `node_modules/x` is treated as direct — which
222
- * over-counts roots slightly on v1 and is the best that file can support.
223
- */
224
- const collectRoots = (
225
- builder: TreeBuilder,
226
- entries: ReadonlyMap<string, NpmEntry>,
227
- source: Record<string, unknown>,
228
- path: string,
229
- importer: string | undefined
230
- ): void => {
231
- // Resolution starts from the member's own directory, so a dependency it
232
- // nests privately wins over the hoisted copy the same rule node applies,
233
- // which is why the walk-up is shared with ordinary edges.
234
- const collectFrom = (from: string, project: Record<string, unknown>): void => {
235
- for (const kind of ROOT_KINDS) {
236
- const field = fieldByRootKind[kind]
237
- for (const [name, specifier] of Object.entries(stringRecord(project[field]))) {
238
- const targetPath = resolveNestedEdge(entries, from, name)
239
- const target = targetPath === null ? undefined : entries.get(targetPath)
240
- if (target === undefined) continue
241
- builder.addRoot({ name: target.realName, version: target.version, kind, specifier })
242
- }
243
- }
244
- }
245
-
246
- if (importer !== undefined) {
247
- const key = importerKey(importer)
248
- const project = source[key]
249
- if (!isRecord(project)) notCovered(path, importer, workspacePaths(source))
250
- else collectFrom(key, project)
251
- return
252
- }
253
-
254
- // No member named: every project the file records contributes, which is what
255
- // pointing --file straight at a monorepo's lockfile should mean.
256
- const projects = Object.entries(source).flatMap(([key, value]) =>
257
- isRecord(value) && !key.includes("node_modules/") ? [[key, value] as const] : []
258
- )
259
-
260
- if (projects.length > 0) {
261
- for (const [key, project] of projects) collectFrom(key, project)
262
- return
263
- }
264
-
265
- // v1 records no project entry at all, so every top-level install is direct.
266
- for (const entry of entries.values()) {
267
- if (entry.path.includes(NESTING)) continue
268
- builder.addRoot({ name: entry.realName, version: entry.version, kind: LockedRootKind.Prod })
269
- }
270
- }
271
-
272
- /** The workspace paths a v2/v3 lockfile records, for a "does not cover" message. */
273
- const workspacePaths = (source: Record<string, unknown>): ReadonlyArray<string> =>
274
- Object.keys(source).flatMap((key) =>
275
- key === "" ? "." : key.includes("node_modules/") ? [] : key
276
- )
277
-
278
- /**
279
- * npm records non-registry sources as a URL in `resolved`. Registry tarballs
280
- * are ordinary http(s); anything with a `git+`, `file:` or bare path form is
281
- * something we cannot fetch.
282
- */
283
- const isRegistryUrl = (url: string): boolean =>
284
- url.startsWith("http://") || url.startsWith("https://")
285
-
286
- const resolveNestedEdge = (
287
- entries: ReadonlyMap<string, NpmEntry>,
288
- fromPath: string,
289
- name: string
290
- ): string | null => {
291
- let scope = fromPath
292
- for (;;) {
293
- const candidate = scope === "" ? `node_modules/${name}` : `${scope}${NESTING}${name}`
294
- if (entries.has(candidate)) return candidate
295
- if (scope === "") return null
296
- const at = scope.lastIndexOf(NESTING)
297
- scope = at === -1 ? "" : scope.slice(0, at)
298
- }
299
- }
300
-
301
- /** `node_modules/@babel/core` -> `@babel/core`; a workspace path -> `null`. */
302
- const installNameFromPath = (entryPath: string): string | null => {
303
- const marker = "node_modules/"
304
- const at = entryPath.lastIndexOf(marker)
305
- if (at === -1) return null
306
- const name = entryPath.slice(at + marker.length)
307
- return name.length === 0 ? null : name
308
- }
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
+ };