@effected/lockfiles 0.5.1 → 0.6.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.
package/Lockfile.js CHANGED
@@ -182,10 +182,15 @@ var Lockfile = class Lockfile extends Schema.Class("Lockfile")({
182
182
  return ResolvedPackage.make({
183
183
  name: realName,
184
184
  version: pkg.version,
185
+ instanceId: pkg.instanceId,
185
186
  ...pkg.integrity !== void 0 ? { integrity: pkg.integrity } : {},
186
187
  isWorkspace: pkg.isWorkspace,
187
188
  relativePath: pkg.relativePath,
188
- dependencies: pkg.dependencies
189
+ dependencies: pkg.dependencies,
190
+ peerDependencies: pkg.peerDependencies,
191
+ peerDependenciesMeta: pkg.peerDependenciesMeta,
192
+ resolved: pkg.resolved,
193
+ unresolvedEdges: pkg.unresolvedEdges
189
194
  });
190
195
  });
191
196
  const workspaceDependencies = this.workspaceDependencies.map((dep) => {
package/LockfileFormat.js CHANGED
@@ -3,7 +3,7 @@ import { Option, Schema } from "effect";
3
3
  //#region src/LockfileFormat.ts
4
4
  /**
5
5
  * The lockfile formats this package parses: bun's `bun.lock` (JSONC), npm's
6
- * `package-lock.json` (v2/v3 JSON), pnpm's `pnpm-lock.yaml` and yarn Berry's
6
+ * `package-lock.json` (JSON), pnpm's `pnpm-lock.yaml` and yarn Berry's
7
7
  * `yarn.lock` (both YAML).
8
8
  *
9
9
  * @remarks
@@ -12,6 +12,10 @@ import { Option, Schema } from "effect";
12
12
  * Berry only; classic (v1) `yarn.lock` is not YAML and fails
13
13
  * `Lockfile.parse` with a typed `LockfileParseError`.
14
14
  *
15
+ * The format literal says nothing about which *versions* of that format are
16
+ * supported: pnpm `lockfileVersion` 9+ and npm `lockfileVersion` 3+ parse,
17
+ * and older ones fail typed. See `Lockfile.parse`.
18
+ *
15
19
  * @public
16
20
  */
17
21
  const LockfileFormat = Schema.Literals([
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Lockfile parsing for [Effect](https://effect.website) v4: bun (`bun.lock`), npm (`package-lock.json` v2/v3), pnpm (`pnpm-lock.yaml`) and yarn Berry (`yarn.lock`) all normalized into one `Lockfile` schema model, plus pure integrity checking of that model against workspace manifests. Four formats, one model, no IO and no external runtime dependencies.
8
+ Lockfile parsing for [Effect](https://effect.website) v4: bun (`bun.lock`), npm (`package-lock.json`), pnpm (`pnpm-lock.yaml`) and yarn Berry (`yarn.lock`) all normalized into one `Lockfile` schema model, plus pure integrity checking of that model against workspace manifests. Four formats, one model, no IO and no external runtime dependencies.
9
9
 
10
10
  > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
11
  > development against a single pinned Effect v4 prerelease. Packages graduate to
@@ -77,10 +77,44 @@ Effect.runPromise(program).then(console.log);
77
77
  | Format | Filename | Dialect | Notes |
78
78
  | ------ | -------- | ------- | ----- |
79
79
  | `"bun"` | `bun.lock` | JSONC | Trusted dependencies preserved on `BunExtension` |
80
- | `"npm"` | `package-lock.json` | JSON | v2 and v3 |
81
- | `"pnpm"` | `pnpm-lock.yaml` | YAML | Catalogs preserved on `PnpmExtension`; see below |
80
+ | `"npm"` | `package-lock.json` | JSON | `lockfileVersion` 3+ — v2 and older fail typed |
81
+ | `"pnpm"` | `pnpm-lock.yaml` | YAML | `lockfileVersion` 9+ — older fails typed. Catalogs preserved on `PnpmExtension`; see below |
82
82
  | `"yarn"` | `yarn.lock` | YAML | Berry only — classic v1 fails typed |
83
83
 
84
+ ### Supported lockfile versions
85
+
86
+ The gate is on the **lockfile format version**, which is the only version a lockfile records:
87
+
88
+ | Format | Minimum | Older input |
89
+ | ------ | ------- | ----------- |
90
+ | pnpm | `lockfileVersion` 9 | `LockfileParseError`, `stage: "validation"` |
91
+ | npm | `lockfileVersion` 3 | `LockfileParseError`, `stage: "validation"` |
92
+ | bun, yarn | not gated | — |
93
+
94
+ The `cause` is a structured `{ _tag: "UnsupportedLockfileVersion", format, lockfileVersion, minimumSupported, message }`, so a consumer can distinguish "your lockfile is too old" from "your lockfile is malformed" without parsing prose. `isUnsupportedLockfileVersion` is the exported predicate that narrows to it — discriminate on the tag, never on `message`, which is a summary rather than contract:
95
+
96
+ ```ts
97
+ import { isUnsupportedLockfileVersion, Lockfile } from "@effected/lockfiles";
98
+ import { Effect } from "effect";
99
+
100
+ declare const content: string;
101
+
102
+ const program = Lockfile.parse(content, { format: "pnpm" }).pipe(
103
+ Effect.catchTag("LockfileParseError", (error) =>
104
+ isUnsupportedLockfileVersion(error.cause)
105
+ ? Effect.fail(`lockfileVersion ${error.cause.lockfileVersion}; need ${error.cause.minimumSupported}+`)
106
+ : Effect.fail("malformed lockfile"),
107
+ ),
108
+ );
109
+ // a pre-v9 pnpm lockfile takes the first branch, a truncated one the second
110
+ ```
111
+
112
+ `cause` stays an open channel deliberately: it carries whatever the delegated parsing engines throw, so declaring it as a closed union would present an open channel as an exhaustive one. The predicate is what makes narrowing it honest.
113
+
114
+ Older formats are rejected rather than parsed because they cannot answer the questions the model now asks: pre-v9 pnpm has no `snapshots:` section, and npm v1/v2 trees record no per-instance nesting, so both would decode into rows with no resolution data.
115
+
116
+ **Tested against** pnpm 11.22.0, npm 11.19.0, bun 1.3.14 and yarn 4.9.1 — a separate claim from the gate, and deliberately so. A lockfile does not record which package manager wrote it, and the mapping is many-to-one (pnpm 9, 10 and 11 all write format `9.0`), so no manager-version requirement could be enforced here even in principle.
117
+
84
118
  ## Errors
85
119
 
86
120
  `Lockfile.parse` is the only fallible entrypoint, and it fails two ways.
@@ -94,6 +128,21 @@ The framing error exists because `pnpm-lock.yaml` is a YAML **stream**, not a YA
94
128
 
95
129
  yarn defines no document framing at all, so multi-document `yarn.lock` content fails with `"unexpectedDocuments"` rather than being silently truncated to its first document. Where a format states no rule, this package refuses to guess.
96
130
 
131
+ ## Instances and resolution
132
+
133
+ A row in `lockfile.packages` is one package **instance**, not one package. A dependency installed twice under different peers is two rows, and `instanceId` is that row's lockfile-native identity, carried verbatim. Treat it as **opaque**: look an id up in the model, never split or pattern-match one, because its spelling is the format's business and differs per manager.
134
+
135
+ `resolved` maps each dependency name — and each peer name the lockfile records a resolution for — to the `instanceId` it actually resolved to, which is what lets a consumer walk the real graph rather than re-resolving ranges itself. `peerDependencies` and `peerDependenciesMeta` carry the declarations as written, defaulting to `{}`.
136
+
137
+ ```ts
138
+ const [instance] = lockfile.packagesNamed("react-dom");
139
+ const reactId = instance?.resolved["react"];
140
+ const react = lockfile.packages.find((p) => p.instanceId === reactId);
141
+ // react?.version: the version this particular react-dom instance resolved to
142
+ ```
143
+
144
+ `unresolvedEdges` names the edges the lockfile **records** but the model could not name. A name listed there is not absent — it is unanswered, so a missing `resolved` key must not be read as "nothing is there". A gate that cares about completeness checks that array before trusting an empty result.
145
+
97
146
  ## Features
98
147
 
99
148
  - `Lockfile.parse(content, { format })` — the package's only fallible boundary; fails with `LockfileParseError` or `LockfileFramingError`.
@@ -102,7 +151,8 @@ yarn defines no document framing at all, so multi-document `yarn.lock` content f
102
151
  - `LockfileFormat` with `filenameFor` / `fromFilename` — the format literal and its mapping to lockfile filenames.
103
152
  - `LockfileIntegrity.compare(lockfile, manifests)` — total, pure integrity checking with no error channel; the report carries `valid`, `missingWorkspaces`, `extraWorkspaces` and `unsatisfiedConstraints`. Constraint checking is best-effort by design: `workspace:` / `link:` / `file:` specifiers and rows whose range does not parse as SemVer are skipped.
104
153
  - `WorkspaceManifest` — the manifest input shape for integrity checking: a package name plus four optional dependency records. Deliberately a plain value rather than a strict manifest model, so consumers can derive it from anything.
105
- - `ResolvedPackage` — name, version, optional integrity hash, workspace flag and relative path, dependency map.
154
+ - `ResolvedPackage` — one package *instance*: name, version, optional integrity hash, workspace flag and relative path, dependency map, the peer declarations (`peerDependencies`, `peerDependenciesMeta`), the opaque `instanceId`, the `resolved` name-to-instance map and the `unresolvedEdges` the model could not name.
155
+ - `isUnsupportedLockfileVersion` and the `UnsupportedLockfileVersion` type — the predicate that narrows a `LockfileParseError.cause` to a version-gate rejection, so "too old" and "malformed" are told apart on a tag rather than on prose.
106
156
  - `WorkspaceDependency` — a `from`/`to` edge between workspace packages, with `depType` spelled in `@effected/npm`'s `DependencyField` vocabulary and its declared constraint.
107
157
  - `PnpmExtension` and `BunExtension` — format-specific data such as pnpm catalogs and bun trusted dependencies, preserved on the model's optional `extension` field.
108
158
 
@@ -3,12 +3,21 @@ import { IntegrityHash } from "@effected/npm";
3
3
 
4
4
  //#region src/ResolvedPackage.ts
5
5
  const EMPTY_DEPENDENCIES = {};
6
+ const EMPTY_PEER_META = {};
7
+ const EMPTY_EDGE_NAMES = [];
6
8
  /**
7
9
  * A package resolved from a lockfile.
8
10
  *
9
11
  * @remarks
10
12
  * The common shape every format's entries normalize into:
11
13
  *
14
+ * - `instanceId` — the lockfile-native identity of this *instance*, verbatim:
15
+ * pnpm's snapshot key (`react-dom@18.3.1(react@17.0.2)`), npm's full entry
16
+ * key (`node_modules/express/node_modules/debug`), bun's `packages` key
17
+ * (`@acme/lib/react-dom`) or yarn's locator (`react-dom@npm:18.3.1`). It is
18
+ * **opaque** — no scheme is synthesized and none should be parsed by a
19
+ * consumer; it exists so `resolved` can name an instance, and so two
20
+ * peer-resolved variants of one `name@version` stay distinguishable.
12
21
  * - `name` — the resolved package name. For pnpm *workspace* packages
13
22
  * straight out of `Lockfile.parse` this is the importer path until
14
23
  * `Lockfile#withImporterNames` rewrites it.
@@ -24,16 +33,62 @@ const EMPTY_DEPENDENCIES = {};
24
33
  * packages, when the lockfile records one.
25
34
  * - `dependencies` — the package's own dependency map, defaulting to `{}`
26
35
  * both at construction and when decoding serialized data.
36
+ * - `peerDependencies` — the package's *declared* peer ranges, keyed by peer
37
+ * name. Every format records them (pnpm and npm as their own section, bun
38
+ * inline in the package tuple's info object, yarn Berry as an entry
39
+ * section), so this is populated across all four. Declarations only: what
40
+ * the package *asks for*, never what actually resolved.
41
+ * - `peerDependenciesMeta` — the per-peer flags, normalized to
42
+ * `{ optional: boolean }`. bun spells optional peers as an `optionalPeers`
43
+ * array of names rather than a meta object; that spelling is normalized
44
+ * into this shape so one consumer algorithm serves every format. A peer
45
+ * with no meta entry is required.
46
+ * - `resolved` — this instance's *outgoing edges*: dependency (and, where the
47
+ * format records it, peer) name → the `instanceId` that name actually
48
+ * resolved to in this instance's context. This is the answer to "which
49
+ * concrete version of P did X get", which `peerDependencies` deliberately
50
+ * does not answer.
51
+ *
52
+ * Every entry is **verified**: an edge is emitted only when the composed or
53
+ * walked-up identity matches a real instance in the same lockfile. An edge
54
+ * that cannot be resolved honestly is *omitted*, never guessed — a wrong
55
+ * resolved edge is a silent wrong answer, an absent one is a visible gap.
56
+ * yarn records peer resolution virtually and is therefore dependency-edges
57
+ * only.
58
+ *
59
+ * - `unresolvedEdges` — dependency names whose edge the lockfile **records**
60
+ * but this model could not name: a `link:` target that is not a workspace
61
+ * importer, a composed identity matching no instance. It is the companion
62
+ * `resolved` needs to be honest, because an absent key in `resolved` would
63
+ * otherwise mean two different things — "nothing is there" and "something is
64
+ * there that I could not name" — and a consumer reading the first meaning
65
+ * for the second turns this package's gap into its own false positive.
66
+ *
67
+ * A dependency the lockfile simply does not record is **not** an unresolved
68
+ * edge; it is an absence, and must not appear here. npm and bun contribute
69
+ * nothing at all: their sections are *declarations* resolved positionally, so
70
+ * "the walk found nothing" is genuine absence. A fail-closed signal that is
71
+ * always on is a signal nobody reads.
72
+ *
73
+ * All four record fields — `dependencies`, `peerDependencies`,
74
+ * `peerDependenciesMeta` and `resolved` — default to `{}` at construction and
75
+ * when decoding, and `unresolvedEdges` defaults to `[]`. An absent section is
76
+ * therefore an empty collection, never `undefined`.
27
77
  *
28
78
  * @public
29
79
  */
30
80
  var ResolvedPackage = class extends Schema.Class("ResolvedPackage")({
31
81
  name: Schema.NonEmptyString,
32
82
  version: Schema.String,
83
+ instanceId: Schema.NonEmptyString,
33
84
  integrity: Schema.optionalKey(IntegrityHash),
34
85
  isWorkspace: Schema.Boolean,
35
86
  relativePath: Schema.optionalKey(Schema.String),
36
- dependencies: Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_DEPENDENCIES)), Schema.withConstructorDefault(Effect.succeed(EMPTY_DEPENDENCIES)))
87
+ dependencies: Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_DEPENDENCIES)), Schema.withConstructorDefault(Effect.succeed(EMPTY_DEPENDENCIES))),
88
+ peerDependencies: Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_DEPENDENCIES)), Schema.withConstructorDefault(Effect.succeed(EMPTY_DEPENDENCIES))),
89
+ peerDependenciesMeta: Schema.Record(Schema.String, Schema.Struct({ optional: Schema.Boolean })).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_PEER_META)), Schema.withConstructorDefault(Effect.succeed(EMPTY_PEER_META))),
90
+ resolved: Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_DEPENDENCIES)), Schema.withConstructorDefault(Effect.succeed(EMPTY_DEPENDENCIES))),
91
+ unresolvedEdges: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_EDGE_NAMES)), Schema.withConstructorDefault(Effect.succeed(EMPTY_EDGE_NAMES)))
37
92
  }) {};
38
93
 
39
94
  //#endregion
@@ -0,0 +1,35 @@
1
+ //#region src/UnsupportedLockfileVersion.ts
2
+ /**
3
+ * Whether a `LockfileParseError.cause` is an {@link UnsupportedLockfileVersion}.
4
+ *
5
+ * @remarks
6
+ * `LockfileParseError.cause` is deliberately `Schema.Defect` — it has to carry
7
+ * whatever the delegated jsonc/yaml/JSON engines throw, which is genuinely
8
+ * open. Widening that field to a union would not make it closed, and would
9
+ * misrepresent an open channel as an exhaustive one. So the *type* is exported
10
+ * and narrowing is a predicate, which is the honest tool for an intentionally
11
+ * open value.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { Lockfile, isUnsupportedLockfileVersion } from "@effected/lockfiles";
16
+ * import { Effect } from "effect";
17
+ *
18
+ * const parsed = Lockfile.parse(text, { format: "npm" }).pipe(
19
+ * Effect.catchTag("LockfileParseError", (error) =>
20
+ * isUnsupportedLockfileVersion(error.cause)
21
+ * ? Effect.fail(`upgrade: ${error.cause.format} needs lockfileVersion ${error.cause.minimumSupported}+`)
22
+ * : Effect.fail("malformed lockfile"),
23
+ * ),
24
+ * );
25
+ * ```
26
+ *
27
+ * @param cause - the `cause` of a `LockfileParseError`, or any unknown value
28
+ * @returns `true` when `cause` is the version-gate record
29
+ *
30
+ * @public
31
+ */
32
+ const isUnsupportedLockfileVersion = (cause) => typeof cause === "object" && cause !== null && Object.hasOwn(cause, "_tag") && cause._tag === "UnsupportedLockfileVersion" && typeof cause.format === "string" && typeof cause.minimumSupported === "number";
33
+
34
+ //#endregion
35
+ export { isUnsupportedLockfileVersion };
package/index.d.ts CHANGED
@@ -63,7 +63,7 @@ declare class ImporterDependency extends ImporterDependency_base {}
63
63
  //#region src/LockfileFormat.d.ts
64
64
  /**
65
65
  * The lockfile formats this package parses: bun's `bun.lock` (JSONC), npm's
66
- * `package-lock.json` (v2/v3 JSON), pnpm's `pnpm-lock.yaml` and yarn Berry's
66
+ * `package-lock.json` (JSON), pnpm's `pnpm-lock.yaml` and yarn Berry's
67
67
  * `yarn.lock` (both YAML).
68
68
  *
69
69
  * @remarks
@@ -72,6 +72,10 @@ declare class ImporterDependency extends ImporterDependency_base {}
72
72
  * Berry only; classic (v1) `yarn.lock` is not YAML and fails
73
73
  * `Lockfile.parse` with a typed `LockfileParseError`.
74
74
  *
75
+ * The format literal says nothing about which *versions* of that format are
76
+ * supported: pnpm `lockfileVersion` 9+ and npm `lockfileVersion` 3+ parse,
77
+ * and older ones fail typed. See `Lockfile.parse`.
78
+ *
75
79
  * @public
76
80
  */
77
81
  declare const LockfileFormat: Schema.Literals<readonly ["bun", "npm", "pnpm", "yarn"]>;
@@ -197,6 +201,7 @@ declare class PnpmExtension extends PnpmExtension_base {}
197
201
  declare const ResolvedPackage_base: Schema.Class<ResolvedPackage, Schema.Struct<{
198
202
  readonly name: Schema.NonEmptyString;
199
203
  readonly version: Schema.String;
204
+ readonly instanceId: Schema.NonEmptyString;
200
205
  readonly integrity: Schema.optionalKey<Schema.brand<Schema.String, "IntegrityHash"> & {
201
206
  isSri: (value: string) => boolean;
202
207
  isCorepack: (value: string) => boolean;
@@ -208,6 +213,12 @@ declare const ResolvedPackage_base: Schema.Class<ResolvedPackage, Schema.Struct<
208
213
  readonly isWorkspace: Schema.Boolean;
209
214
  readonly relativePath: Schema.optionalKey<Schema.String>;
210
215
  readonly dependencies: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>>;
216
+ readonly peerDependencies: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>>;
217
+ readonly peerDependenciesMeta: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.Struct<{
218
+ readonly optional: Schema.Boolean;
219
+ }>>, never>>;
220
+ readonly resolved: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>>;
221
+ readonly unresolvedEdges: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Array<Schema.String>, never>>;
211
222
  }>, {}>;
212
223
  /**
213
224
  * A package resolved from a lockfile.
@@ -215,6 +226,13 @@ declare const ResolvedPackage_base: Schema.Class<ResolvedPackage, Schema.Struct<
215
226
  * @remarks
216
227
  * The common shape every format's entries normalize into:
217
228
  *
229
+ * - `instanceId` — the lockfile-native identity of this *instance*, verbatim:
230
+ * pnpm's snapshot key (`react-dom@18.3.1(react@17.0.2)`), npm's full entry
231
+ * key (`node_modules/express/node_modules/debug`), bun's `packages` key
232
+ * (`@acme/lib/react-dom`) or yarn's locator (`react-dom@npm:18.3.1`). It is
233
+ * **opaque** — no scheme is synthesized and none should be parsed by a
234
+ * consumer; it exists so `resolved` can name an instance, and so two
235
+ * peer-resolved variants of one `name@version` stay distinguishable.
218
236
  * - `name` — the resolved package name. For pnpm *workspace* packages
219
237
  * straight out of `Lockfile.parse` this is the importer path until
220
238
  * `Lockfile#withImporterNames` rewrites it.
@@ -230,6 +248,47 @@ declare const ResolvedPackage_base: Schema.Class<ResolvedPackage, Schema.Struct<
230
248
  * packages, when the lockfile records one.
231
249
  * - `dependencies` — the package's own dependency map, defaulting to `{}`
232
250
  * both at construction and when decoding serialized data.
251
+ * - `peerDependencies` — the package's *declared* peer ranges, keyed by peer
252
+ * name. Every format records them (pnpm and npm as their own section, bun
253
+ * inline in the package tuple's info object, yarn Berry as an entry
254
+ * section), so this is populated across all four. Declarations only: what
255
+ * the package *asks for*, never what actually resolved.
256
+ * - `peerDependenciesMeta` — the per-peer flags, normalized to
257
+ * `{ optional: boolean }`. bun spells optional peers as an `optionalPeers`
258
+ * array of names rather than a meta object; that spelling is normalized
259
+ * into this shape so one consumer algorithm serves every format. A peer
260
+ * with no meta entry is required.
261
+ * - `resolved` — this instance's *outgoing edges*: dependency (and, where the
262
+ * format records it, peer) name → the `instanceId` that name actually
263
+ * resolved to in this instance's context. This is the answer to "which
264
+ * concrete version of P did X get", which `peerDependencies` deliberately
265
+ * does not answer.
266
+ *
267
+ * Every entry is **verified**: an edge is emitted only when the composed or
268
+ * walked-up identity matches a real instance in the same lockfile. An edge
269
+ * that cannot be resolved honestly is *omitted*, never guessed — a wrong
270
+ * resolved edge is a silent wrong answer, an absent one is a visible gap.
271
+ * yarn records peer resolution virtually and is therefore dependency-edges
272
+ * only.
273
+ *
274
+ * - `unresolvedEdges` — dependency names whose edge the lockfile **records**
275
+ * but this model could not name: a `link:` target that is not a workspace
276
+ * importer, a composed identity matching no instance. It is the companion
277
+ * `resolved` needs to be honest, because an absent key in `resolved` would
278
+ * otherwise mean two different things — "nothing is there" and "something is
279
+ * there that I could not name" — and a consumer reading the first meaning
280
+ * for the second turns this package's gap into its own false positive.
281
+ *
282
+ * A dependency the lockfile simply does not record is **not** an unresolved
283
+ * edge; it is an absence, and must not appear here. npm and bun contribute
284
+ * nothing at all: their sections are *declarations* resolved positionally, so
285
+ * "the walk found nothing" is genuine absence. A fail-closed signal that is
286
+ * always on is a signal nobody reads.
287
+ *
288
+ * All four record fields — `dependencies`, `peerDependencies`,
289
+ * `peerDependenciesMeta` and `resolved` — default to `{}` at construction and
290
+ * when decoding, and `unresolvedEdges` defaults to `[]`. An absent section is
291
+ * therefore an empty collection, never `undefined`.
233
292
  *
234
293
  * @public
235
294
  */
@@ -490,5 +549,70 @@ declare class LockfileIntegrity extends LockfileIntegrity_base {
490
549
  static compare(lockfile: Lockfile, manifests: ReadonlyArray<WorkspaceManifest>): LockfileIntegrity;
491
550
  }
492
551
  //#endregion
493
- export { BunExtension, ImporterDependency, Lockfile, LockfileFormat, LockfileFramingError, LockfileImporter, LockfileIntegrity, LockfileParseError, type PnpmCatalogs, PnpmExtension, ResolvedPackage, WorkspaceDependency, WorkspaceManifest, filenameFor, filenamesFor, fromFilename };
552
+ //#region src/UnsupportedLockfileVersion.d.ts
553
+ /**
554
+ * The cause a {@link LockfileParseError} carries when a lockfile predates the
555
+ * supported format version.
556
+ *
557
+ * @remarks
558
+ * `@effected/lockfiles` parses pnpm `lockfileVersion` 9+ and npm
559
+ * `lockfileVersion` 3+. An older format fails typed at `stage: "validation"`,
560
+ * and the failure carries this record rather than an engine error, so a
561
+ * consumer can tell **"your lockfile is too old" from "your lockfile is
562
+ * malformed" without parsing prose**.
563
+ *
564
+ * `message` is for humans and its wording is not contract; discriminate on
565
+ * `_tag` — through {@link isUnsupportedLockfileVersion}, which is why that
566
+ * predicate exists.
567
+ *
568
+ * bun and yarn are ungated (neither records a comparable format-version line),
569
+ * so `format` is narrowed to the two that are.
570
+ *
571
+ * @public
572
+ */
573
+ interface UnsupportedLockfileVersion {
574
+ /** Discriminant. */
575
+ readonly _tag: "UnsupportedLockfileVersion";
576
+ /** The gated format that rejected the input. */
577
+ readonly format: "npm" | "pnpm";
578
+ /** The `lockfileVersion` found, verbatim — a number or a string like `"6.0"`. */
579
+ readonly lockfileVersion: number | string;
580
+ /** The lowest format version this package parses for that format. */
581
+ readonly minimumSupported: number;
582
+ /** A human-readable summary. Not contract — narrow on `_tag` instead. */
583
+ readonly message: string;
584
+ }
585
+ /**
586
+ * Whether a `LockfileParseError.cause` is an {@link UnsupportedLockfileVersion}.
587
+ *
588
+ * @remarks
589
+ * `LockfileParseError.cause` is deliberately `Schema.Defect` — it has to carry
590
+ * whatever the delegated jsonc/yaml/JSON engines throw, which is genuinely
591
+ * open. Widening that field to a union would not make it closed, and would
592
+ * misrepresent an open channel as an exhaustive one. So the *type* is exported
593
+ * and narrowing is a predicate, which is the honest tool for an intentionally
594
+ * open value.
595
+ *
596
+ * @example
597
+ * ```ts
598
+ * import { Lockfile, isUnsupportedLockfileVersion } from "@effected/lockfiles";
599
+ * import { Effect } from "effect";
600
+ *
601
+ * const parsed = Lockfile.parse(text, { format: "npm" }).pipe(
602
+ * Effect.catchTag("LockfileParseError", (error) =>
603
+ * isUnsupportedLockfileVersion(error.cause)
604
+ * ? Effect.fail(`upgrade: ${error.cause.format} needs lockfileVersion ${error.cause.minimumSupported}+`)
605
+ * : Effect.fail("malformed lockfile"),
606
+ * ),
607
+ * );
608
+ * ```
609
+ *
610
+ * @param cause - the `cause` of a `LockfileParseError`, or any unknown value
611
+ * @returns `true` when `cause` is the version-gate record
612
+ *
613
+ * @public
614
+ */
615
+ declare const isUnsupportedLockfileVersion: (cause: unknown) => cause is UnsupportedLockfileVersion;
616
+ //#endregion
617
+ export { BunExtension, ImporterDependency, Lockfile, LockfileFormat, LockfileFramingError, LockfileImporter, LockfileIntegrity, LockfileParseError, type PnpmCatalogs, PnpmExtension, ResolvedPackage, type UnsupportedLockfileVersion, WorkspaceDependency, WorkspaceManifest, filenameFor, filenamesFor, fromFilename, isUnsupportedLockfileVersion };
494
618
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -7,5 +7,6 @@ import { PnpmExtension } from "./PnpmExtension.js";
7
7
  import { LockfileFormat, filenameFor, filenamesFor, fromFilename } from "./LockfileFormat.js";
8
8
  import { Lockfile, LockfileFramingError, LockfileParseError } from "./Lockfile.js";
9
9
  import { LockfileIntegrity, WorkspaceManifest } from "./LockfileIntegrity.js";
10
+ import { isUnsupportedLockfileVersion } from "./UnsupportedLockfileVersion.js";
10
11
 
11
- export { BunExtension, ImporterDependency, Lockfile, LockfileFormat, LockfileFramingError, LockfileImporter, LockfileIntegrity, LockfileParseError, PnpmExtension, ResolvedPackage, WorkspaceDependency, WorkspaceManifest, filenameFor, filenamesFor, fromFilename };
12
+ export { BunExtension, ImporterDependency, Lockfile, LockfileFormat, LockfileFramingError, LockfileImporter, LockfileIntegrity, LockfileParseError, PnpmExtension, ResolvedPackage, WorkspaceDependency, WorkspaceManifest, filenameFor, filenamesFor, fromFilename, isUnsupportedLockfileVersion };
package/internal/bun.js CHANGED
@@ -1,20 +1,42 @@
1
1
  import { BunExtension } from "../BunExtension.js";
2
2
  import { LockfileImporter } from "../LockfileImporter.js";
3
3
  import { ResolvedPackage } from "../ResolvedPackage.js";
4
- import { extractWorkspaceDeps, importerDependencies, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
5
- import { Effect, Schema } from "effect";
4
+ import { extractWorkspaceDeps, importerDependencies, peerDeclarations, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
5
+ import { Effect, Exit, Schema } from "effect";
6
6
  import { Jsonc } from "@effected/jsonc";
7
7
 
8
8
  //#region src/internal/bun.ts
9
9
  const DepRecord = Schema.optionalKey(Schema.Record(Schema.String, Schema.String));
10
+ const OptionalPeers = Schema.optionalKey(Schema.Array(Schema.String));
10
11
  const BunWorkspaceEntry = Schema.Struct({
11
12
  name: Schema.optionalKey(Schema.String),
12
13
  version: Schema.optionalKey(Schema.String),
13
14
  dependencies: DepRecord,
14
15
  devDependencies: DepRecord,
15
16
  peerDependencies: DepRecord,
16
- optionalDependencies: DepRecord
17
+ optionalDependencies: DepRecord,
18
+ optionalPeers: OptionalPeers
17
19
  });
20
+ /**
21
+ * The info object at package-tuple index 2. bun's tuple shape is
22
+ * under-documented upstream, so it is read permissively and *out of band*: a
23
+ * shape that does not decode is skipped (the package still lands, with no
24
+ * peers) rather than failing the whole parse.
25
+ *
26
+ * @internal
27
+ */
28
+ const BunPackageInfo = Schema.Struct({
29
+ dependencies: DepRecord,
30
+ devDependencies: DepRecord,
31
+ optionalDependencies: DepRecord,
32
+ peerDependencies: DepRecord,
33
+ optionalPeers: OptionalPeers
34
+ });
35
+ const decodeBunPackageInfo = Schema.decodeUnknownExit(BunPackageInfo);
36
+ const readBunPackageInfo = (value) => {
37
+ const exit = decodeBunPackageInfo(value);
38
+ return Exit.isSuccess(exit) ? exit.value : void 0;
39
+ };
18
40
  const BunLockfileRaw = Schema.Struct({
19
41
  lockfileVersion: Schema.Number,
20
42
  workspaces: Schema.optionalKey(Schema.Record(Schema.String, BunWorkspaceEntry)),
@@ -37,8 +59,60 @@ const parseBun = (content) => Effect.gen(function* () {
37
59
  const validated = yield* Schema.decodeUnknownEffect(BunLockfileRaw)(parsed).pipe(Effect.mapError(validationFailure));
38
60
  return yield* toFields(validated);
39
61
  });
62
+ /**
63
+ * Resolve one entry's outgoing edges by replaying bun's own key scheme.
64
+ *
65
+ * bun encodes install position in the `packages` key: a nested install is
66
+ * keyed `<parent-key>/<name>`, so `"react-dom/react"` is literally "the react
67
+ * react-dom got". Package names are themselves slash-bearing when scoped, so
68
+ * the parent chain is **not** computed by splitting on `/` — it is read off
69
+ * the key space, taking those prefixes of the key that are themselves keys,
70
+ * longest first. Deepest wins; the root (`<name>` alone) is tried last.
71
+ *
72
+ * Only keys that exist are emitted, so an edge bun did not record is omitted
73
+ * rather than invented.
74
+ *
75
+ * @internal
76
+ */
77
+ const resolveBunEdges = (key, sections, keys) => {
78
+ const names = /* @__PURE__ */ new Set();
79
+ for (const section of sections) {
80
+ if (!section) continue;
81
+ for (const name of Object.keys(section)) if (name !== "") names.add(name);
82
+ }
83
+ if (names.size === 0) return {};
84
+ const prefixes = [key];
85
+ for (let i = key.indexOf("/"); i !== -1; i = key.indexOf("/", i + 1)) {
86
+ const candidate = key.slice(0, i);
87
+ if (keys.has(candidate)) prefixes.push(candidate);
88
+ }
89
+ prefixes.sort((a, b) => b.length - a.length);
90
+ prefixes.push("");
91
+ const edges = /* @__PURE__ */ new Map();
92
+ for (const name of names) for (const prefix of prefixes) {
93
+ const candidate = prefix === "" ? name : `${prefix}/${name}`;
94
+ if (keys.has(candidate)) {
95
+ edges.set(name, candidate);
96
+ break;
97
+ }
98
+ }
99
+ return Object.fromEntries(edges);
100
+ };
101
+ const infoSections = (info) => [
102
+ info?.dependencies,
103
+ info?.devDependencies,
104
+ info?.optionalDependencies,
105
+ info?.peerDependencies
106
+ ];
107
+ const workspaceSections = (entry) => [
108
+ entry.dependencies,
109
+ entry.devDependencies,
110
+ entry.optionalDependencies,
111
+ entry.peerDependencies
112
+ ];
40
113
  const toFields = (raw) => Effect.gen(function* () {
41
114
  const packages = [];
115
+ const keys = new Set(raw.packages ? Object.keys(raw.packages) : []);
42
116
  const workspaceNames = /* @__PURE__ */ new Set();
43
117
  const workspaceEntries = /* @__PURE__ */ new Map();
44
118
  const importers = [];
@@ -51,11 +125,16 @@ const toFields = (raw) => Effect.gen(function* () {
51
125
  if (wsPath === "") continue;
52
126
  const name = wsEntry.name === void 0 || wsEntry.name === "" ? wsPath : wsEntry.name;
53
127
  workspaceNames.add(name);
128
+ const instanceId = keys.has(name) ? name : wsPath;
129
+ if (instanceId === "") continue;
54
130
  packages.push(ResolvedPackage.make({
55
131
  name,
56
132
  version: wsEntry.version ?? "0.0.0",
133
+ instanceId,
57
134
  isWorkspace: true,
58
- relativePath: wsPath
135
+ relativePath: wsPath,
136
+ ...peerDeclarations(wsEntry.peerDependencies, void 0, wsEntry.optionalPeers),
137
+ resolved: resolveBunEdges(instanceId, workspaceSections(wsEntry), keys)
59
138
  }));
60
139
  workspaceEntries.set(name, {
61
140
  ...wsEntry.dependencies ? { dependencies: wsEntry.dependencies } : {},
@@ -65,7 +144,8 @@ const toFields = (raw) => Effect.gen(function* () {
65
144
  });
66
145
  }
67
146
  }
68
- if (raw.packages) for (const tuple of Object.values(raw.packages)) {
147
+ if (raw.packages) for (const [key, tuple] of Object.entries(raw.packages)) {
148
+ if (key === "") continue;
69
149
  if (tuple.length < 1) continue;
70
150
  const first = tuple[0];
71
151
  if (typeof first !== "string") continue;
@@ -75,11 +155,15 @@ const toFields = (raw) => Effect.gen(function* () {
75
155
  const version = first.slice(atIdx + 1);
76
156
  if (workspaceNames.has(name)) continue;
77
157
  const integrity = yield* toIntegrityHash(tuple.length >= 4 && typeof tuple[3] === "string" ? tuple[3] : void 0);
158
+ const info = tuple.length >= 3 ? readBunPackageInfo(tuple[2]) : void 0;
78
159
  packages.push(ResolvedPackage.make({
79
160
  name,
80
161
  version,
162
+ instanceId: key,
81
163
  ...integrity !== void 0 ? { integrity } : {},
82
- isWorkspace: false
164
+ isWorkspace: false,
165
+ ...peerDeclarations(info?.peerDependencies, void 0, info?.optionalPeers),
166
+ resolved: resolveBunEdges(key, infoSections(info), keys)
83
167
  }));
84
168
  }
85
169
  const workspaceDependencies = extractWorkspaceDeps(workspaceEntries, workspaceNames);
package/internal/npm.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { LockfileImporter } from "../LockfileImporter.js";
2
2
  import { ResolvedPackage } from "../ResolvedPackage.js";
3
- import { extractWorkspaceDeps, importerDependencies, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
3
+ import { extractWorkspaceDeps, importerDependencies, peerDeclarations, requireLockfileVersion, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
4
4
  import { Effect, Schema } from "effect";
5
5
 
6
6
  //#region src/internal/npm.ts
7
7
  const DepRecord = Schema.optionalKey(Schema.Record(Schema.String, Schema.String));
8
+ const PeerMetaRecord = Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.optionalKey(Schema.Boolean) })));
8
9
  const NpmPackageEntry = Schema.Struct({
9
10
  name: Schema.optionalKey(Schema.String),
10
11
  version: Schema.optionalKey(Schema.String),
@@ -15,8 +16,22 @@ const NpmPackageEntry = Schema.Struct({
15
16
  dependencies: DepRecord,
16
17
  devDependencies: DepRecord,
17
18
  peerDependencies: DepRecord,
19
+ peerDependenciesMeta: PeerMetaRecord,
18
20
  optionalDependencies: DepRecord
19
21
  });
22
+ /**
23
+ * The version gate's own input: `lockfileVersion` and nothing else.
24
+ *
25
+ * The gate has to read the version *before* the shape decode, because the
26
+ * shape it would decode against is the shape of a supported version. `packages`
27
+ * is a required key here and a v1 tree does not have one, so a shape-first
28
+ * order reports npm v1 as **malformed** rather than as **too old** — losing
29
+ * exactly the distinction the `UnsupportedLockfileVersion` cause exists to
30
+ * carry.
31
+ *
32
+ * @internal
33
+ */
34
+ const NpmVersionProbe = Schema.Struct({ lockfileVersion: Schema.Union([Schema.Number, Schema.String]) });
20
35
  const NpmLockfileRaw = Schema.Struct({
21
36
  name: Schema.optionalKey(Schema.String),
22
37
  version: Schema.optionalKey(Schema.String),
@@ -25,10 +40,73 @@ const NpmLockfileRaw = Schema.Struct({
25
40
  packages: Schema.Record(Schema.String, NpmPackageEntry)
26
41
  });
27
42
  const NODE_MODULES_PREFIX = "node_modules/";
43
+ const NESTED_NODE_MODULES = "/node_modules/";
28
44
  /**
29
- * Parse npm `package-lock.json` (v2/v3) content into the unified field
30
- * bundle. Native `JSON.parse` inside `Effect.try` — its throw on hostile
31
- * input (including V8's `RangeError` on pathological depth) lands typed as
45
+ * Where a `packages` key's package name starts: after the **last**
46
+ * `node_modules/` segment, not the first.
47
+ *
48
+ * npm encodes the whole install position in the key, so
49
+ * `node_modules/express/node_modules/debug` is *debug*, nested under express —
50
+ * and `packages/lib/node_modules/react` is *react*, nested under a workspace
51
+ * directory and carrying no `node_modules/` prefix at all. Returns `-1` for a
52
+ * key that is not a package position (the root `""` entry, a workspace path
53
+ * entry), which the caller skips.
54
+ *
55
+ * @internal
56
+ */
57
+ const packageNameIndex = (key) => {
58
+ const nested = key.lastIndexOf(NESTED_NODE_MODULES);
59
+ if (nested !== -1) return nested + 14;
60
+ return key.startsWith(NODE_MODULES_PREFIX) ? 13 : -1;
61
+ };
62
+ /**
63
+ * Resolve one entry's outgoing edges by replaying node resolution over the key
64
+ * space: from the depending package's own directory, try
65
+ * `<prefix>/node_modules/<name>`, then strip one path segment and try again,
66
+ * out to the root — **deepest first**, first hit wins. That order is the whole
67
+ * algorithm: outermost-first would return the hoisted copy and silently
68
+ * mis-report every shadowed dependency.
69
+ *
70
+ * Only keys that actually exist in the lockfile are emitted, so an edge npm
71
+ * did not record is omitted rather than invented.
72
+ *
73
+ * @internal
74
+ */
75
+ const resolveNpmEdges = (start, sections, packages) => {
76
+ const names = /* @__PURE__ */ new Set();
77
+ for (const section of sections) {
78
+ if (!section) continue;
79
+ for (const name of Object.keys(section)) if (name !== "") names.add(name);
80
+ }
81
+ if (names.size === 0) return {};
82
+ const prefixes = [];
83
+ for (let prefix = start;;) {
84
+ prefixes.push(prefix);
85
+ if (prefix === "") break;
86
+ const slash = prefix.lastIndexOf("/");
87
+ prefix = slash === -1 ? "" : prefix.slice(0, slash);
88
+ }
89
+ const edges = /* @__PURE__ */ new Map();
90
+ for (const name of names) for (const prefix of prefixes) {
91
+ const candidate = prefix === "" ? `${NODE_MODULES_PREFIX}${name}` : `${prefix}${NESTED_NODE_MODULES}${name}`;
92
+ if (Object.hasOwn(packages, candidate)) {
93
+ edges.set(name, candidate);
94
+ break;
95
+ }
96
+ }
97
+ return Object.fromEntries(edges);
98
+ };
99
+ const entrySections = (entry) => [
100
+ entry?.dependencies,
101
+ entry?.devDependencies,
102
+ entry?.optionalDependencies,
103
+ entry?.peerDependencies
104
+ ];
105
+ /**
106
+ * Parse npm `package-lock.json` content into the unified field bundle —
107
+ * `lockfileVersion` 3 and newer; v2 and older fail typed at validation.
108
+ * Native `JSON.parse` inside `Effect.try` — its throw on hostile input
109
+ * (including V8's `RangeError` on pathological depth) lands typed as
32
110
  * `stage: "syntax"`.
33
111
  *
34
112
  * @internal
@@ -38,6 +116,8 @@ const parseNpm = (content) => Effect.gen(function* () {
38
116
  try: () => JSON.parse(content),
39
117
  catch: syntaxFailure
40
118
  });
119
+ const probe = yield* Schema.decodeUnknownEffect(NpmVersionProbe)(raw).pipe(Effect.mapError(validationFailure));
120
+ yield* requireLockfileVersion("npm", probe.lockfileVersion);
41
121
  const validated = yield* Schema.decodeUnknownEffect(NpmLockfileRaw)(raw).pipe(Effect.mapError(validationFailure));
42
122
  return yield* toFields(validated);
43
123
  });
@@ -51,22 +131,30 @@ const toFields = (raw) => Effect.gen(function* () {
51
131
  path: ".",
52
132
  dependencies: importerDependencies(rootEntry, (s) => ({ specifier: s }))
53
133
  }));
54
- for (const [key, entry] of Object.entries(raw.packages)) if (key.startsWith(NODE_MODULES_PREFIX) && entry.link === true) {
55
- const name = (entry.resolved !== void 0 ? raw.packages[entry.resolved] : void 0)?.name ?? entry.name ?? key.slice(13);
56
- if (name !== "") workspaceNames.add(name);
134
+ for (const [key, entry] of Object.entries(raw.packages)) {
135
+ const nameIndex = packageNameIndex(key);
136
+ if (nameIndex !== -1 && entry.link === true) {
137
+ const name = (entry.resolved !== void 0 ? raw.packages[entry.resolved] : void 0)?.name ?? entry.name ?? key.slice(nameIndex);
138
+ if (name !== "") workspaceNames.add(name);
139
+ }
57
140
  }
58
141
  for (const [key, entry] of Object.entries(raw.packages)) {
59
142
  if (key === "") continue;
60
- if (key.startsWith(NODE_MODULES_PREFIX) && entry.link === true) {
143
+ const nameIndex = packageNameIndex(key);
144
+ if (nameIndex === -1) continue;
145
+ if (entry.link === true) {
61
146
  const resolved = entry.resolved;
62
147
  const wsEntry = resolved !== void 0 ? raw.packages[resolved] : void 0;
63
- const name = wsEntry?.name ?? entry.name ?? key.slice(13);
148
+ const name = wsEntry?.name ?? entry.name ?? key.slice(nameIndex);
64
149
  if (name === "") continue;
65
150
  packages.push(ResolvedPackage.make({
66
151
  name,
67
152
  version: wsEntry?.version ?? "0.0.0",
153
+ instanceId: key,
68
154
  isWorkspace: true,
69
- ...resolved !== void 0 ? { relativePath: resolved } : {}
155
+ ...resolved !== void 0 ? { relativePath: resolved } : {},
156
+ ...peerDeclarations(wsEntry?.peerDependencies, wsEntry?.peerDependenciesMeta),
157
+ resolved: resolveNpmEdges(resolved !== void 0 && resolved !== "" ? resolved : key, entrySections(wsEntry), raw.packages)
70
158
  }));
71
159
  if (wsEntry) workspaceEntries.set(name, {
72
160
  ...wsEntry.dependencies ? { dependencies: wsEntry.dependencies } : {},
@@ -78,16 +166,19 @@ const toFields = (raw) => Effect.gen(function* () {
78
166
  path: resolved,
79
167
  dependencies: wsEntry ? importerDependencies(wsEntry, (s) => ({ specifier: s })) : []
80
168
  }));
81
- } else if (key.startsWith(NODE_MODULES_PREFIX)) {
82
- const name = key.slice(13);
169
+ } else {
170
+ const name = key.slice(nameIndex);
83
171
  if (name !== "" && entry.version !== void 0) {
84
172
  const integrity = yield* toIntegrityHash(entry.integrity);
85
173
  packages.push(ResolvedPackage.make({
86
174
  name,
87
175
  version: entry.version,
176
+ instanceId: key,
88
177
  ...integrity !== void 0 ? { integrity } : {},
89
178
  isWorkspace: false,
90
- dependencies: entry.dependencies ?? {}
179
+ dependencies: entry.dependencies ?? {},
180
+ ...peerDeclarations(entry.peerDependencies, entry.peerDependenciesMeta),
181
+ resolved: resolveNpmEdges(key, entrySections(entry), raw.packages)
91
182
  }));
92
183
  }
93
184
  }
package/internal/pnpm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { LockfileImporter } from "../LockfileImporter.js";
2
2
  import { ResolvedPackage } from "../ResolvedPackage.js";
3
- import { extractWorkspaceDeps, framingFailure, importerDependencies, splitPeerSuffix, toIntegrityHash, validationFailure } from "./shared.js";
3
+ import { extractWorkspaceDeps, framingFailure, importerDependencies, peerDeclarations, requireLockfileVersion, splitPeerSuffix, toIntegrityHash, validationFailure } from "./shared.js";
4
4
  import { PnpmExtension } from "../PnpmExtension.js";
5
5
  import { selectPnpmDocument } from "./documents.js";
6
6
  import { Effect, Schema } from "effect";
@@ -16,6 +16,18 @@ const PnpmImporter = Schema.Struct({
16
16
  peerDependencies: PnpmImporterDeps,
17
17
  optionalDependencies: PnpmImporterDeps
18
18
  });
19
+ /**
20
+ * The version gate's own input: `lockfileVersion` and nothing else.
21
+ *
22
+ * The gate has to read the version *before* the shape decode, because the
23
+ * shape it decodes against is the shape of a supported version. `importers` is
24
+ * a required key here and a pre-v9 single-project lockfile has none — it
25
+ * records its dependencies at the top level — so a shape-first order reports a
26
+ * lockfile we reject for being too old as merely malformed instead.
27
+ *
28
+ * @internal
29
+ */
30
+ const PnpmVersionProbe = Schema.Struct({ lockfileVersion: Schema.Union([Schema.String, Schema.Number]) });
19
31
  const PnpmLockfileRaw = Schema.Struct({
20
32
  lockfileVersion: Schema.Union([Schema.String, Schema.Number]),
21
33
  settings: Schema.optionalKey(Schema.Struct({
@@ -28,7 +40,17 @@ const PnpmLockfileRaw = Schema.Struct({
28
40
  version: Schema.String
29
41
  })])))),
30
42
  importers: Schema.Record(Schema.String, PnpmImporter),
31
- packages: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({ resolution: Schema.optionalKey(Schema.Struct({ integrity: Schema.optionalKey(Schema.String) })) })))
43
+ packages: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({
44
+ resolution: Schema.optionalKey(Schema.Struct({ integrity: Schema.optionalKey(Schema.String) })),
45
+ peerDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
46
+ peerDependenciesMeta: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.optionalKey(Schema.Boolean) }))),
47
+ dependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
48
+ optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String))
49
+ }))),
50
+ snapshots: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({
51
+ dependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
52
+ optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String))
53
+ })))
32
54
  });
33
55
  /**
34
56
  * Parse pnpm `pnpm-lock.yaml` content into the unified field bundle.
@@ -48,10 +70,103 @@ const PnpmLockfileRaw = Schema.Struct({
48
70
  */
49
71
  const parsePnpm = (content) => Effect.gen(function* () {
50
72
  const { document, documents } = yield* selectPnpmDocument(content);
73
+ const probe = yield* Schema.decodeUnknownEffect(PnpmVersionProbe)(document).pipe(Effect.mapError(validationFailure));
74
+ yield* requireLockfileVersion("pnpm", probe.lockfileVersion);
51
75
  const validated = yield* Schema.decodeUnknownEffect(PnpmLockfileRaw)(document).pipe(Effect.mapError(validationFailure));
52
76
  if (Object.keys(validated.importers).length === 0) return yield* Effect.fail(framingFailure("noImporters", documents));
53
77
  return yield* toFields(validated);
54
78
  });
79
+ /**
80
+ * Resolve one instance's outgoing edges to instance ids.
81
+ *
82
+ * pnpm names a resolved dependency as `name` → `version`, where the version may
83
+ * itself carry a peer suffix (`1.6.0(react@17.0.2)`); the referenced instance's
84
+ * key is those two composed back together. The composition is *verified*
85
+ * against the lockfile's own key set rather than trusted — the composed key is
86
+ * v9's bare `name@version` and nothing else, because the format gate admits no
87
+ * lockfile that spells keys the pre-v9 leading-slash way — and an edge that
88
+ * matches nothing is **omitted**. That is what keeps a `link:`/`file:`
89
+ * resolution, which names no instance in this lockfile, from becoming a
90
+ * plausible lie.
91
+ *
92
+ * @internal
93
+ */
94
+ const resolveEdges = (sections, instanceIds) => {
95
+ const edges = /* @__PURE__ */ new Map();
96
+ const unnameable = /* @__PURE__ */ new Set();
97
+ for (const section of sections) {
98
+ if (!section) continue;
99
+ for (const [name, version] of Object.entries(section)) {
100
+ if (name === "") continue;
101
+ if (version.startsWith(LINK_PREFIX)) {
102
+ const target = resolveLinkTarget("", version.slice(LINK_PREFIX.length));
103
+ if (target !== void 0 && instanceIds.has(target)) edges.set(name, target);
104
+ else unnameable.add(name);
105
+ continue;
106
+ }
107
+ const bare = `${name}@${version}`;
108
+ if (instanceIds.has(bare)) edges.set(name, bare);
109
+ else unnameable.add(name);
110
+ }
111
+ }
112
+ return {
113
+ resolved: Object.fromEntries(edges),
114
+ unresolvedEdges: [...unnameable].sort()
115
+ };
116
+ };
117
+ /** The protocol pnpm records a workspace-directory resolution under. @internal */
118
+ const LINK_PREFIX = "link:";
119
+ /**
120
+ * Normalize a `link:` target against the linking importer's path, so a
121
+ * workspace edge can name the workspace importer's instance id.
122
+ * Total: a target that walks above the root yields `undefined`.
123
+ *
124
+ * @internal
125
+ */
126
+ const resolveLinkTarget = (importerPath, target) => {
127
+ const segments = [];
128
+ for (const segment of `${importerPath}/${target}`.split("/")) {
129
+ if (segment === "" || segment === ".") continue;
130
+ if (segment === "..") {
131
+ if (segments.pop() === void 0) return void 0;
132
+ continue;
133
+ }
134
+ segments.push(segment);
135
+ }
136
+ return segments.length === 0 ? void 0 : segments.join("/");
137
+ };
138
+ /**
139
+ * Resolve one importer's outgoing edges. pnpm records `{ specifier, version }`
140
+ * per importer dependency, where the version is either a registry version (the
141
+ * instance-id composition {@link resolveEdges} performs) or a `link:` target
142
+ * naming another importer — normalized against this importer's own path. Both
143
+ * are checked against the lockfile's instance ids before being emitted.
144
+ *
145
+ * @internal
146
+ */
147
+ const resolveImporterEdges = (importer, importerPath, instanceIds) => {
148
+ const edges = /* @__PURE__ */ new Map();
149
+ const unnameable = /* @__PURE__ */ new Set();
150
+ for (const group of importerDepGroups(importer)) {
151
+ if (!group) continue;
152
+ for (const [name, info] of Object.entries(group)) {
153
+ if (name === "") continue;
154
+ if (info.version.startsWith(LINK_PREFIX)) {
155
+ const target = resolveLinkTarget(importerPath, info.version.slice(5));
156
+ if (target !== void 0 && instanceIds.has(target)) edges.set(name, target);
157
+ else unnameable.add(name);
158
+ continue;
159
+ }
160
+ const bare = `${name}@${info.version}`;
161
+ if (instanceIds.has(bare)) edges.set(name, bare);
162
+ else unnameable.add(name);
163
+ }
164
+ }
165
+ return {
166
+ resolved: Object.fromEntries(edges),
167
+ unresolvedEdges: [...unnameable].sort()
168
+ };
169
+ };
55
170
  const toVersionMap = (deps) => {
56
171
  if (!deps) return void 0;
57
172
  return Object.fromEntries(Object.entries(deps).map(([name, info]) => [name, info.specifier]));
@@ -92,28 +207,50 @@ const toFields = (raw) => Effect.gen(function* () {
92
207
  }
93
208
  for (const path of Object.keys(raw.importers)) if (path !== "." && path !== "") workspaceNames.add(path);
94
209
  const packages = [];
95
- for (const importerPath of Object.keys(raw.importers)) {
210
+ const snapshots = raw.snapshots;
211
+ const instanceIds = /* @__PURE__ */ new Set([
212
+ ...snapshots ? Object.keys(snapshots) : [],
213
+ ...raw.packages ? Object.keys(raw.packages) : [],
214
+ ...Object.keys(raw.importers)
215
+ ]);
216
+ const emitted = /* @__PURE__ */ new Set();
217
+ for (const [importerPath, importer] of Object.entries(raw.importers)) {
96
218
  if (importerPath === "." || importerPath === "") continue;
97
219
  packages.push(ResolvedPackage.make({
98
220
  name: importerPath,
99
221
  version: "0.0.0",
222
+ instanceId: importerPath,
100
223
  isWorkspace: true,
101
- relativePath: importerPath
224
+ relativePath: importerPath,
225
+ ...resolveImporterEdges(importer, importerPath, instanceIds)
102
226
  }));
103
227
  }
104
- if (raw.packages) for (const [key, pkg] of Object.entries(raw.packages)) {
105
- const { plain: bare } = splitPeerSuffix(key);
228
+ const emit = (instanceId, meta, edges) => Effect.gen(function* () {
229
+ const { plain: bare } = splitPeerSuffix(instanceId);
106
230
  const atIndex = bare.lastIndexOf("@");
107
- if (atIndex <= 0) continue;
231
+ if (atIndex <= 0) return;
108
232
  const name = bare.slice(0, atIndex);
109
233
  const version = bare.slice(atIndex + 1);
110
- const integrity = yield* toIntegrityHash(pkg.resolution?.integrity);
234
+ const integrity = yield* toIntegrityHash(meta?.resolution?.integrity);
235
+ emitted.add(instanceId);
111
236
  packages.push(ResolvedPackage.make({
112
237
  name,
113
238
  version,
239
+ instanceId,
114
240
  ...integrity !== void 0 ? { integrity } : {},
115
- isWorkspace: false
241
+ isWorkspace: false,
242
+ ...peerDeclarations(meta?.peerDependencies, meta?.peerDependenciesMeta),
243
+ ...resolveEdges(edges, instanceIds)
116
244
  }));
245
+ });
246
+ if (snapshots) for (const [key, snapshot] of Object.entries(snapshots)) {
247
+ const plain = splitPeerSuffix(key).plain;
248
+ yield* emit(key, raw.packages !== void 0 && Object.hasOwn(raw.packages, plain) ? raw.packages[plain] : void 0, [snapshot.dependencies, snapshot.optionalDependencies]);
249
+ }
250
+ const covered = new Set([...emitted].map((key) => splitPeerSuffix(key).plain));
251
+ if (raw.packages) for (const [key, pkg] of Object.entries(raw.packages)) {
252
+ if (covered.has(key)) continue;
253
+ yield* emit(key, pkg, [pkg.dependencies, pkg.optionalDependencies]);
117
254
  }
118
255
  const workspaceDependencies = extractWorkspaceDeps(workspaceEntries, workspaceNames);
119
256
  const extension = PnpmExtension.make({
@@ -114,6 +114,79 @@ const importerDependencies = (entry, read) => {
114
114
  }
115
115
  return deps;
116
116
  };
117
+ const EMPTY_PEERS = {
118
+ peerDependencies: {},
119
+ peerDependenciesMeta: {}
120
+ };
121
+ /**
122
+ * Normalize one format's peer declarations into the unified pair.
123
+ *
124
+ * Every format records the ranges the same way (a name→range map), but the
125
+ * optional flag has two spellings: a `peerDependenciesMeta` object (pnpm, npm,
126
+ * yarn Berry) and bun's `optionalPeers` array of names. Both are accepted and
127
+ * collapse to `{ optional: boolean }` per peer, so a consumer reads one shape
128
+ * regardless of which manager wrote the lockfile.
129
+ *
130
+ * Absence yields empty records, never `undefined` — an absent section means
131
+ * "declares no peers", which is a fact, not a gap. Records are built with
132
+ * `Object.fromEntries` (own-property semantics), so a `__proto__` peer name
133
+ * neither pollutes nor drops.
134
+ *
135
+ * @internal
136
+ */
137
+ const peerDeclarations = (peers, meta, optionalPeers) => {
138
+ if (peers === void 0 && meta === void 0 && optionalPeers === void 0) return EMPTY_PEERS;
139
+ const flags = /* @__PURE__ */ new Map();
140
+ if (meta !== void 0) for (const [name, value] of Object.entries(meta)) flags.set(name, value?.optional === true);
141
+ if (optionalPeers !== void 0) for (const name of optionalPeers) flags.set(name, true);
142
+ return {
143
+ peerDependencies: peers === void 0 ? {} : Object.fromEntries(Object.entries(peers)),
144
+ peerDependenciesMeta: Object.fromEntries([...flags].map(([name, optional]) => [name, { optional }]))
145
+ };
146
+ };
147
+ /**
148
+ * The minimum lockfile-format version each gated format is parsed at.
149
+ *
150
+ * This is a deliberate narrowing of the supported input domain, not an
151
+ * implementation detail: an older lockfile fails typed rather than parsing
152
+ * into a model that cannot answer resolution questions. Note the gate is on
153
+ * the **lockfile format version**, which is the only version a lockfile
154
+ * records — the writing package manager's version is not recoverable from the
155
+ * file, so no manager-version claim could be enforced here.
156
+ *
157
+ * @internal
158
+ */
159
+ const MINIMUM_LOCKFILE_VERSION = {
160
+ pnpm: 9,
161
+ npm: 3
162
+ };
163
+ /**
164
+ * Fail typed when a lockfile predates the supported format version.
165
+ *
166
+ * Routed through {@link validationFailure} rather than {@link framingFailure}:
167
+ * the document was located perfectly well, so this is a shape judgement about
168
+ * a located document, not a framing problem. The cause is a structured record
169
+ * rather than an engine error, so a consumer can tell "your lockfile is too
170
+ * old" from "your lockfile is malformed" without parsing a message.
171
+ *
172
+ * A version that is not a number at all (`lockfileVersion: "next"`) is not a
173
+ * supported version either, and fails the same way.
174
+ *
175
+ * @internal
176
+ */
177
+ const requireLockfileVersion = (format, raw) => {
178
+ const minimum = MINIMUM_LOCKFILE_VERSION[format];
179
+ const parsed = typeof raw === "number" ? raw : Number.parseFloat(raw);
180
+ if (Number.isFinite(parsed) && parsed >= minimum) return Effect.void;
181
+ const cause = {
182
+ _tag: "UnsupportedLockfileVersion",
183
+ format,
184
+ lockfileVersion: raw,
185
+ minimumSupported: minimum,
186
+ message: `${format} lockfileVersion ${JSON.stringify(raw)} is not supported: @effected/lockfiles parses ${format} lockfileVersion ${minimum} and newer`
187
+ };
188
+ return Effect.fail(validationFailure(cause));
189
+ };
117
190
  /** @internal */
118
191
  const syntaxFailure = (cause) => ({
119
192
  stage: "syntax",
@@ -162,4 +235,4 @@ const extractWorkspaceDeps = (workspaces, workspaceNames) => {
162
235
  };
163
236
 
164
237
  //#endregion
165
- export { DEP_TYPES, extractWorkspaceDeps, framingFailure, importerDependencies, isWorkspaceSpecifier, splitPeerSuffix, syntaxFailure, toIntegrityHash, validationFailure };
238
+ export { DEP_TYPES, MINIMUM_LOCKFILE_VERSION, extractWorkspaceDeps, framingFailure, importerDependencies, isWorkspaceSpecifier, peerDeclarations, requireLockfileVersion, splitPeerSuffix, syntaxFailure, toIntegrityHash, validationFailure };
package/internal/yarn.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ResolvedPackage } from "../ResolvedPackage.js";
2
- import { extractWorkspaceDeps, toIntegrityHash, validationFailure } from "./shared.js";
2
+ import { extractWorkspaceDeps, peerDeclarations, toIntegrityHash, validationFailure } from "./shared.js";
3
3
  import { selectSoleDocument } from "./documents.js";
4
4
  import { Effect, Schema } from "effect";
5
5
 
@@ -12,6 +12,7 @@ const YarnEntry = Schema.Struct({
12
12
  dependencies: DepRecord,
13
13
  devDependencies: DepRecord,
14
14
  peerDependencies: DepRecord,
15
+ peerDependenciesMeta: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.optionalKey(Schema.Boolean) }))),
15
16
  optionalDependencies: DepRecord,
16
17
  checksum: Schema.optionalKey(Schema.String),
17
18
  languageName: Schema.optionalKey(Schema.String),
@@ -45,6 +46,13 @@ const toFields = (lockfileVersion, decoded) => Effect.gen(function* () {
45
46
  const packages = [];
46
47
  const workspaceNames = /* @__PURE__ */ new Set();
47
48
  const workspaceEntries = /* @__PURE__ */ new Map();
49
+ const locators = /* @__PURE__ */ new Map();
50
+ for (const [key, entry] of decoded) {
51
+ const descriptors = key.split(", ");
52
+ const locator = entry.resolution ?? descriptors[0];
53
+ if (locator === void 0 || locator === "") continue;
54
+ for (const descriptor of descriptors) if (descriptor !== "") locators.set(descriptor, locator);
55
+ }
48
56
  for (const [key, entry] of decoded) if (entry.linkType === "soft") {
49
57
  const name = extractYarnPackageName(key);
50
58
  if (name !== void 0) workspaceNames.add(name);
@@ -55,12 +63,17 @@ const toFields = (lockfileVersion, decoded) => Effect.gen(function* () {
55
63
  const isWorkspace = entry.linkType === "soft";
56
64
  const relativePath = isWorkspace ? extractYarnWorkspacePath(key) : void 0;
57
65
  const integrity = yield* toIntegrityHash(entry.checksum);
66
+ const instanceId = entry.resolution ?? key.split(", ")[0] ?? "";
67
+ if (instanceId === "") continue;
58
68
  packages.push(ResolvedPackage.make({
59
69
  name,
60
70
  version: entry.version ?? "0.0.0",
71
+ instanceId,
61
72
  ...integrity !== void 0 ? { integrity } : {},
62
73
  isWorkspace,
63
- ...relativePath !== void 0 ? { relativePath } : {}
74
+ ...relativePath !== void 0 ? { relativePath } : {},
75
+ ...peerDeclarations(entry.peerDependencies, entry.peerDependenciesMeta),
76
+ ...resolveYarnEdges(entry, locators)
64
77
  }));
65
78
  if (isWorkspace) {
66
79
  const deps = cleanYarnDeps(entry.dependencies);
@@ -83,6 +96,46 @@ const toFields = (lockfileVersion, decoded) => Effect.gen(function* () {
83
96
  };
84
97
  });
85
98
  /**
99
+ * Resolve one entry's outgoing edges through the descriptor→locator index.
100
+ *
101
+ * A yarn dependency value is a *descriptor* range (`"npm:5.3.0"`), and the
102
+ * lockfile keys enumerate exactly which descriptors resolve to which locator,
103
+ * so the lookup is exact rather than reconstructed.
104
+ *
105
+ * **`devDependencies` is not a section a Berry lockfile has.** yarn folds a
106
+ * workspace's dev declarations into the entry's `dependencies` map — the
107
+ * lockfile a project produces is byte-identical whether a dependency is
108
+ * declared under `dependencies` or `devDependencies` (probed against yarn
109
+ * 4.9.1). Dev edges are therefore already resolved here; iterating
110
+ * `entry.devDependencies` would be dead code, and the schema keeps the field
111
+ * only as permissive scaffolding for hand-edited input.
112
+ *
113
+ * **Dependency edges only.** yarn resolves peers virtually — a peer-bearing
114
+ * package gets a `@virtual:` locator per consumer, and this lockfile shape does
115
+ * not record which one satisfied which peer. Emitting a peer edge here would
116
+ * mean guessing, so peers are left out: an absent edge is a true statement, a
117
+ * plausible one would not be.
118
+ *
119
+ * @internal
120
+ */
121
+ const resolveYarnEdges = (entry, locators) => {
122
+ const edges = /* @__PURE__ */ new Map();
123
+ const unnameable = /* @__PURE__ */ new Set();
124
+ for (const section of [entry.dependencies, entry.optionalDependencies]) {
125
+ if (!section) continue;
126
+ for (const [name, range] of Object.entries(section)) {
127
+ if (name === "") continue;
128
+ const locator = locators.get(`${name}@${range}`);
129
+ if (locator !== void 0) edges.set(name, locator);
130
+ else unnameable.add(name);
131
+ }
132
+ }
133
+ return {
134
+ resolved: Object.fromEntries(edges),
135
+ unresolvedEdges: [...unnameable].sort()
136
+ };
137
+ };
138
+ /**
86
139
  * Extract the package name from a yarn lockfile key. Handles compound keys
87
140
  * (`"a@workspace:*, a@workspace:packages/a"`) via the first descriptor and
88
141
  * `@patch:` descriptors (which embed `@npm:` inside). Total: malformed keys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/lockfiles",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Pure lockfile parsing for bun, npm, pnpm and yarn Berry into one unified Effect schema model, with pure integrity checking against workspace manifests.",
6
6
  "keywords": [