@packall/core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/{Archive.d.ts → archive.d.ts} +5 -5
  2. package/dist/archive.d.ts.map +1 -0
  3. package/dist/{Bundle.d.ts → bundle.d.ts} +51 -36
  4. package/dist/bundle.d.ts.map +1 -0
  5. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  6. package/dist/dependency-range.d.ts.map +1 -0
  7. package/dist/{Download.d.ts → download.d.ts} +7 -7
  8. package/dist/download.d.ts.map +1 -0
  9. package/dist/enums/artifact-kind.d.ts +8 -0
  10. package/dist/enums/artifact-kind.d.ts.map +1 -0
  11. package/dist/enums/edge-kind.d.ts +29 -0
  12. package/dist/enums/edge-kind.d.ts.map +1 -0
  13. package/dist/enums/input-file-kind.d.ts +12 -0
  14. package/dist/enums/input-file-kind.d.ts.map +1 -0
  15. package/dist/enums/layout.d.ts +23 -0
  16. package/dist/enums/layout.d.ts.map +1 -0
  17. package/dist/enums/lockfile-format.d.ts +9 -0
  18. package/dist/enums/lockfile-format.d.ts.map +1 -0
  19. package/dist/enums/phase.d.ts +11 -0
  20. package/dist/enums/phase.d.ts.map +1 -0
  21. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/index.d.ts +44 -28
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2416 -1180
  26. package/dist/index.js.map +1 -1
  27. package/dist/input-file.d.ts +77 -0
  28. package/dist/input-file.d.ts.map +1 -0
  29. package/dist/{Integrity.d.ts → integrity.d.ts} +3 -3
  30. package/dist/integrity.d.ts.map +1 -0
  31. package/dist/{Layout.d.ts → layout.d.ts} +1 -1
  32. package/dist/{Layout.d.ts.map → layout.d.ts.map} +1 -1
  33. package/dist/locked-resolve.d.ts +47 -0
  34. package/dist/locked-resolve.d.ts.map +1 -0
  35. package/dist/lockfile/bun.d.ts +3 -0
  36. package/dist/lockfile/bun.d.ts.map +1 -0
  37. package/dist/lockfile/detect.d.ts +31 -0
  38. package/dist/lockfile/detect.d.ts.map +1 -0
  39. package/dist/lockfile/json.d.ts +23 -0
  40. package/dist/lockfile/json.d.ts.map +1 -0
  41. package/dist/lockfile/names.d.ts +16 -0
  42. package/dist/lockfile/names.d.ts.map +1 -0
  43. package/dist/lockfile/npm.d.ts +3 -0
  44. package/dist/lockfile/npm.d.ts.map +1 -0
  45. package/dist/lockfile/parse.d.ts +23 -0
  46. package/dist/lockfile/parse.d.ts.map +1 -0
  47. package/dist/lockfile/pnpm.d.ts +20 -0
  48. package/dist/lockfile/pnpm.d.ts.map +1 -0
  49. package/dist/lockfile/tree-builder.d.ts +70 -0
  50. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  51. package/dist/lockfile/types.d.ts +70 -0
  52. package/dist/lockfile/types.d.ts.map +1 -0
  53. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  54. package/dist/manifest.d.ts.map +1 -0
  55. package/dist/{Options.d.ts → options.d.ts} +9 -27
  56. package/dist/options.d.ts.map +1 -0
  57. package/dist/{Platform.d.ts → platform.d.ts} +9 -9
  58. package/dist/platform.d.ts.map +1 -0
  59. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  60. package/dist/progress.d.ts.map +1 -0
  61. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  62. package/dist/registry.d.ts.map +1 -0
  63. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  64. package/dist/resolve.d.ts.map +1 -0
  65. package/dist/schemas/lenient.d.ts +38 -0
  66. package/dist/schemas/lenient.d.ts.map +1 -0
  67. package/dist/schemas/package-json.d.ts +44 -0
  68. package/dist/schemas/package-json.d.ts.map +1 -0
  69. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/types/value-of.d.ts +9 -0
  72. package/dist/types/value-of.d.ts.map +1 -0
  73. package/dist/utils/is-record.d.ts +8 -0
  74. package/dist/utils/is-record.d.ts.map +1 -0
  75. package/package.json +3 -2
  76. package/src/{Archive.ts → archive.ts} +92 -92
  77. package/src/{Bundle.ts → bundle.ts} +616 -570
  78. package/src/{DependencyRange.ts → dependency-range.ts} +115 -115
  79. package/src/{Download.ts → download.ts} +133 -132
  80. package/src/enums/artifact-kind.ts +9 -0
  81. package/src/enums/edge-kind.ts +32 -0
  82. package/src/enums/input-file-kind.ts +13 -0
  83. package/src/enums/layout.ts +25 -0
  84. package/src/enums/lockfile-format.ts +10 -0
  85. package/src/enums/phase.ts +12 -0
  86. package/src/{Errors.ts → errors.ts} +87 -5
  87. package/src/index.ts +133 -35
  88. package/src/{InputFile.ts → input-file.ts} +127 -44
  89. package/src/{Integrity.ts → integrity.ts} +121 -122
  90. package/src/locked-resolve.ts +461 -0
  91. package/src/lockfile/bun.ts +207 -0
  92. package/src/lockfile/detect.ts +80 -0
  93. package/src/lockfile/json.ts +132 -0
  94. package/src/lockfile/names.ts +20 -0
  95. package/src/lockfile/npm.ts +308 -0
  96. package/src/lockfile/parse.ts +56 -0
  97. package/src/lockfile/pnpm.ts +225 -0
  98. package/src/lockfile/tree-builder.ts +166 -0
  99. package/src/lockfile/types.ts +74 -0
  100. package/src/{Manifest.ts → manifest.ts} +133 -133
  101. package/src/{Options.ts → options.ts} +7 -27
  102. package/src/{Platform.ts → platform.ts} +23 -23
  103. package/src/{Progress.ts → progress.ts} +106 -106
  104. package/src/{Registry.ts → registry.ts} +115 -121
  105. package/src/{Resolve.ts → resolve.ts} +537 -528
  106. package/src/schemas/lenient.ts +123 -0
  107. package/src/schemas/package-json.ts +27 -0
  108. package/src/{Spec.ts → spec.ts} +11 -19
  109. package/src/types/value-of.ts +8 -0
  110. package/src/utils/is-record.ts +8 -0
  111. package/dist/Archive.d.ts.map +0 -1
  112. package/dist/Bundle.d.ts.map +0 -1
  113. package/dist/DependencyRange.d.ts.map +0 -1
  114. package/dist/Download.d.ts.map +0 -1
  115. package/dist/Errors.d.ts.map +0 -1
  116. package/dist/InputFile.d.ts +0 -46
  117. package/dist/InputFile.d.ts.map +0 -1
  118. package/dist/Integrity.d.ts.map +0 -1
  119. package/dist/Manifest.d.ts.map +0 -1
  120. package/dist/Options.d.ts.map +0 -1
  121. package/dist/Platform.d.ts.map +0 -1
  122. package/dist/Progress.d.ts.map +0 -1
  123. package/dist/Registry.d.ts.map +0 -1
  124. package/dist/Resolve.d.ts.map +0 -1
  125. package/dist/Spec.d.ts.map +0 -1
  126. /package/src/{Layout.ts → layout.ts} +0 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Working out which lockfile — if any — some content is.
3
+ *
4
+ * Detection is by content, matching how `--file` treats everything else: a
5
+ * lockfile renamed to `deps.txt` still parses, and a `package-lock.json` that
6
+ * is actually a package.json still gets read as one.
7
+ */
8
+ import { LockfileFormat } from "../enums/lockfile-format.js"
9
+ import { tryParseJsonc } from "./json.js"
10
+ import { isRecord } from "../utils/is-record.js"
11
+
12
+ /** What a blob of file content turned out to be. */
13
+ export type LockfileDetection =
14
+ | { readonly _tag: "Supported"; readonly format: LockfileFormat }
15
+ /**
16
+ * Recognisably a lockfile, but not one we can read. Named explicitly so the
17
+ * error says "yarn.lock is not supported yet" rather than leaving somebody to
18
+ * work out why their file parsed as a spec list.
19
+ */
20
+ | { readonly _tag: "Unsupported"; readonly label: string; readonly hint: string }
21
+ | { readonly _tag: "NotALockfile" }
22
+
23
+ /**
24
+ * Deliberately cheap and total: it never throws, so `--file` can try this
25
+ * first and fall through to its other shapes when the answer is `NotALockfile`.
26
+ */
27
+ export const detectLockfile = (content: string): LockfileDetection => {
28
+ const trimmed = content.trimStart()
29
+
30
+ // bun's binary lockfile leads with its own magic string. Worth naming,
31
+ // because `bun install` wrote this by default until 1.2 and the fix is one
32
+ // command.
33
+ if (trimmed.startsWith("bun-lockfile-format-v0")) {
34
+ return {
35
+ _tag: "Unsupported",
36
+ label: "a binary bun lockfile (bun.lockb)",
37
+ hint: "Run `bun install --save-text-lockfile` to produce a bun.lock, and pass that instead."
38
+ }
39
+ }
40
+
41
+ if (/^__metadata:/m.test(trimmed) || /^# yarn lockfile v\d/m.test(trimmed)) {
42
+ return {
43
+ _tag: "Unsupported",
44
+ label: "a yarn lockfile",
45
+ hint: "yarn is not supported yet. Point --file at the package.json instead, or pass --lockfile off."
46
+ }
47
+ }
48
+
49
+ if (trimmed.startsWith("{")) return detectJsonLockfile(trimmed)
50
+
51
+ // pnpm is the only YAML one, and it always declares its version first.
52
+ if (/^lockfileVersion:/m.test(trimmed) || /^(importers|snapshots):/m.test(trimmed)) {
53
+ return { _tag: "Supported", format: LockfileFormat.Pnpm }
54
+ }
55
+
56
+ return { _tag: "NotALockfile" }
57
+ }
58
+
59
+ /**
60
+ * Separates the two JSON lockfiles from each other and from a package.json.
61
+ *
62
+ * Both npm and bun write a numeric `lockfileVersion`, so that field alone
63
+ * cannot decide it. bun is the one that carries a `workspaces` map keyed by the
64
+ * empty string; npm keeps the root project under `packages[""]` instead.
65
+ */
66
+ const detectJsonLockfile = (trimmed: string): LockfileDetection => {
67
+ const parsed = tryParseJsonc(trimmed)
68
+ if (!isRecord(parsed)) return { _tag: "NotALockfile" }
69
+
70
+ const workspaces = parsed["workspaces"]
71
+ if (isRecord(workspaces) && Object.hasOwn(workspaces, "")) {
72
+ return { _tag: "Supported", format: LockfileFormat.Bun }
73
+ }
74
+
75
+ if (typeof parsed["lockfileVersion"] === "number") {
76
+ return { _tag: "Supported", format: LockfileFormat.Npm }
77
+ }
78
+
79
+ return { _tag: "NotALockfile" }
80
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Narrowing helpers for the untyped JSON and YAML a lockfile parses into.
3
+ *
4
+ * Everything here takes `unknown` and hands back something narrowed, which is
5
+ * what keeps the parsers free of casts against files nobody controls. The
6
+ * shape-level narrowing is Effect Schema — the same decoders the registry
7
+ * response boundary uses — so "tolerated" means one thing across the codebase
8
+ * rather than being re-decided per parser.
9
+ */
10
+ import * as Option from "effect/Option"
11
+ import * as Schema from "effect/Schema"
12
+ import { LockfileError } from "../errors.js"
13
+ import { OptionalFlagRecordSchema, StringRecordSchema } from "../schemas/lenient.js"
14
+ import { isRecord } from "../utils/is-record.js"
15
+
16
+ export const stringOr = (value: unknown, fallback: string): string =>
17
+ typeof value === "string" ? value : fallback
18
+
19
+ const decodeStringRecord = Schema.decodeUnknownOption(StringRecordSchema)
20
+
21
+ /** Keeps only the string-valued members of an untyped object. */
22
+ export const stringRecord = (value: unknown): Readonly<Record<string, string>> =>
23
+ Option.getOrElse(decodeStringRecord(value), (): Record<string, string> => ({}))
24
+
25
+ const decodeOptionalFlags = Schema.decodeUnknownOption(OptionalFlagRecordSchema)
26
+
27
+ /** Names a package marked its peers optional with. */
28
+ export const optionalPeerNames = (value: unknown): ReadonlySet<string> => {
29
+ const flags = Option.getOrElse(
30
+ decodeOptionalFlags(value),
31
+ (): Record<string, { readonly optional: boolean }> => ({})
32
+ )
33
+ return new Set(
34
+ Object.entries(flags).flatMap(([name, meta]) => (meta.optional ? name : []))
35
+ )
36
+ }
37
+
38
+ export const parseJsonObject = (path: string, content: string): Record<string, unknown> => {
39
+ const parsed = tryParseJsonc(content)
40
+ if (parsed === undefined) {
41
+ throw new LockfileError(path, "not valid JSON")
42
+ }
43
+ if (!isRecord(parsed)) {
44
+ throw new LockfileError(path, "top level is not an object")
45
+ }
46
+ return parsed
47
+ }
48
+
49
+ /**
50
+ * Parses JSON, tolerating the comments and trailing commas bun writes.
51
+ *
52
+ * `bun.lock` is JSONC by design — bun puts explanatory comments in it — so
53
+ * plain `JSON.parse` rejects real lockfiles. Returns `undefined` rather than
54
+ * throwing so detection can use it as a probe.
55
+ */
56
+ export const tryParseJsonc = (content: string): unknown => {
57
+ try {
58
+ return JSON.parse(content)
59
+ } catch {
60
+ try {
61
+ return JSON.parse(stripJsonc(content))
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Removes comments and trailing commas.
70
+ *
71
+ * String-aware, because a `//` inside a tarball URL is not a comment and an
72
+ * escaped quote does not end a string. Both appear in every real lockfile.
73
+ */
74
+ export const stripJsonc = (content: string): string => {
75
+ let out = ""
76
+ let index = 0
77
+ let inString = false
78
+
79
+ while (index < content.length) {
80
+ const char = content[index]
81
+
82
+ if (inString) {
83
+ out += char
84
+ if (char === "\\") {
85
+ out += content[index + 1] ?? ""
86
+ index += 2
87
+ continue
88
+ }
89
+ if (char === '"') inString = false
90
+ index += 1
91
+ continue
92
+ }
93
+
94
+ if (char === '"') {
95
+ inString = true
96
+ out += char
97
+ index += 1
98
+ continue
99
+ }
100
+
101
+ if (char === "/" && content[index + 1] === "/") {
102
+ const end = content.indexOf("\n", index)
103
+ index = end === -1 ? content.length : end
104
+ continue
105
+ }
106
+
107
+ if (char === "/" && content[index + 1] === "*") {
108
+ const end = content.indexOf("*/", index + 2)
109
+ index = end === -1 ? content.length : end + 2
110
+ continue
111
+ }
112
+
113
+ // A comma is trailing when the next thing that is not whitespace closes the
114
+ // structure it sits in.
115
+ if (char === ",") {
116
+ const next = content.slice(index + 1).search(/\S/)
117
+ const following = next === -1 ? "" : content[index + 1 + next]
118
+ if (following === "}" || following === "]") {
119
+ index += 1
120
+ continue
121
+ }
122
+ }
123
+
124
+ out += char
125
+ index += 1
126
+ }
127
+
128
+ return out
129
+ }
130
+
131
+ export const describeError = (error: unknown): string =>
132
+ error instanceof Error ? error.message : String(error)
@@ -0,0 +1,20 @@
1
+ import { LockfileFormat } from "../enums/lockfile-format.js"
2
+
3
+ /**
4
+ * The file name each supported manager writes, in detection order.
5
+ *
6
+ * Ordered: `package-lock.json` before `npm-shrinkwrap.json` before pnpm's and
7
+ * bun's, so a directory holding several is searched the way npm itself would.
8
+ */
9
+ export const formatByLockfileName = {
10
+ "package-lock.json": LockfileFormat.Npm,
11
+ "npm-shrinkwrap.json": LockfileFormat.Npm,
12
+ "pnpm-lock.yaml": LockfileFormat.Pnpm,
13
+ "bun.lock": LockfileFormat.Bun
14
+ } as const satisfies Record<string, LockfileFormat>
15
+
16
+ /** The names a given manager writes. */
17
+ export const lockfileNamesFor = (format: LockfileFormat): ReadonlyArray<string> =>
18
+ Object.entries(formatByLockfileName).flatMap(([name, candidate]) =>
19
+ candidate === format ? name : []
20
+ )
@@ -0,0 +1,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 { 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
+ }
@@ -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
+ }