@effected/lockfiles 0.12.0 → 0.13.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.
@@ -16,8 +16,10 @@ import { DependencyField, DependencySpecifier } from "@effected/npm";
16
16
  * comparable across refs and printable. pnpm records `{ specifier, version }`
17
17
  * per importer dependency, and the parser splits pnpm's peer-disambiguation
18
18
  * suffix off into `peerSuffix`, so `version` is always the plain version — or
19
- * a non-registry resolution (`link:../utils`, `file:...`), passed through
20
- * verbatim. bun and npm record resolved versions on their package entries
19
+ * a non-registry resolution (`link:../utils`, `file:...`). A `file:`
20
+ * resolution is suffixed like a registry version when the package declares
21
+ * peers and is split the same way; a `link:` is never suffixed and passes
22
+ * through verbatim. bun and npm record resolved versions on their package entries
21
23
  * instead, so for those formats `version` is absent and a consumer joins by
22
24
  * `name` against `Lockfile.packages`.
23
25
  * - `peerSuffix` — pnpm's peer-disambiguation context: the raw parenthesized
package/Lockfile.js CHANGED
@@ -44,9 +44,10 @@ var LockfileParseError = class extends Schema.TaggedError()("LockfileParseError"
44
44
  * lockfile document could be located in it.
45
45
  *
46
46
  * @remarks
47
- * `pnpm-lock.yaml` is a YAML **stream**. pnpm 11 writes a
48
- * config-dependencies preamble document ahead of the lockfile whenever the
49
- * workspace uses `configDependencies`, so the file holds two documents. The
47
+ * `pnpm-lock.yaml` is a YAML **stream**. pnpm 11 and 12 write an env
48
+ * preamble document ahead of the lockfile whenever the workspace declares
49
+ * `configDependencies` or `devEngines.packageManager`, so the file holds two
50
+ * documents. The
50
51
  * lockfile is the last one — pnpm composes the preamble as a prefix — and a
51
52
  * parser that reads only the first document gets the preamble: a document
52
53
  * that *validates*, and yields a lockfile with an empty workspace. This error
@@ -55,20 +56,24 @@ var LockfileParseError = class extends Schema.TaggedError()("LockfileParseError"
55
56
  * - `format` — which format was being parsed.
56
57
  * - `documents` — how many YAML documents the stream carried.
57
58
  * - `reason`:
58
- * - `"noLockfileDocument"` — the stream carries no lockfile document. An
59
- * env-only `pnpm-lock.yaml` (a config-dependencies preamble and nothing
60
- * after it) reads this way, as does empty content. pnpm itself treats
61
- * such a file as having no lockfile.
59
+ * - `"noLockfileDocument"` — the stream carries no lockfile document:
60
+ * empty content, or a pnpm stream whose lockfile position is empty. That
61
+ * includes an env preamble followed by an empty document unless the
62
+ * caller passes `configOnly`: pnpm writes those same bytes for a
63
+ * config-dependency-only workspace with no root `package.json` *and* for
64
+ * a workspace whose first install failed after its config dependencies
65
+ * were installed, so only the caller can say which it is. With
66
+ * `configOnly`, `Lockfile.parse` reads that stream as an empty lockfile
67
+ * versioned by the preamble; an empty lockfile position with no preamble
68
+ * fails either way.
62
69
  * - `"noImporters"` — the located document declares no importers, so it
63
70
  * describes no workspace. pnpm always records at least the root importer.
64
71
  * - `"unexpectedDocuments"` — the stream carries more documents than the
65
72
  * format's framing defines: several in a format that defines none
66
- * (yarn), or more than the env preamble and the lockfile when reading a
67
- * pnpm env preamble. Rather than silently picking one, parsing refuses to
68
- * guess. For pnpm the limit is the env reader's alone:
69
- * `PnpmEnvLockfile` enforces at most two documents, while
70
- * `Lockfile.parse` reads the last document of a stream of any count and
71
- * never fails with this reason.
73
+ * (yarn), or more than the env preamble and the lockfile in a pnpm
74
+ * stream. Rather than silently picking one, parsing refuses to guess.
75
+ * `Lockfile.parse` and `PnpmEnvLockfile` share one pnpm splitter, so
76
+ * both enforce the same at-most-two limit.
72
77
  *
73
78
  * It carries typed fields rather than a `cause`: unlike
74
79
  * {@link LockfileParseError}, there is no underlying engine failure to wrap —
@@ -107,11 +112,11 @@ const materializeFailure = (format, failure) => failure.stage === "framing" ? ne
107
112
  stage: failure.stage,
108
113
  cause: failure.cause
109
114
  });
110
- const dispatch = (format, content) => {
115
+ const dispatch = (format, content, configOnly) => {
111
116
  switch (format) {
112
117
  case "bun": return parseBun(content);
113
118
  case "npm": return parseNpm(content);
114
- case "pnpm": return parsePnpm(content);
119
+ case "pnpm": return parsePnpm(content, configOnly);
115
120
  case "yarn": return parseYarn(content);
116
121
  }
117
122
  };
@@ -158,7 +163,14 @@ var Lockfile = class Lockfile extends Schema.Class("Lockfile")({
158
163
  *
159
164
  * @param content - The lockfile text (this package does no IO; the caller
160
165
  * reads the file).
161
- * @param options - The lockfile format to parse as.
166
+ * @param options - `format` is the lockfile format to parse as.
167
+ * `configOnly` is the caller's assertion that the workspace has **no root
168
+ * `package.json`**, so a pnpm env preamble followed by an empty main
169
+ * document is a config-dependency-only workspace and reads as an empty
170
+ * lockfile versioned by the preamble. Without it that stream fails
171
+ * {@link LockfileFramingError} with `noLockfileDocument`, because the same
172
+ * bytes are also what an interrupted first install leaves behind. It
173
+ * loosens nothing else, and is ignored for non-pnpm formats.
162
174
  * @returns An `Effect` succeeding with the {@link Lockfile}, or failing
163
175
  * with {@link LockfileParseError} (malformed text or the wrong shape) or
164
176
  * {@link LockfileFramingError} (the text parsed, but no lockfile document
@@ -166,7 +178,7 @@ var Lockfile = class Lockfile extends Schema.Class("Lockfile")({
166
178
  * multi-document `pnpm-lock.yaml` needs it).
167
179
  */
168
180
  static parse = Effect.fn("Lockfile.parse")(function* (content, options) {
169
- const fields = yield* dispatch(options.format, content).pipe(Effect.mapError((failure) => materializeFailure(options.format, failure)));
181
+ const fields = yield* dispatch(options.format, content, options.configOnly === true).pipe(Effect.mapError((failure) => materializeFailure(options.format, failure)));
170
182
  return Lockfile.make({
171
183
  format: options.format,
172
184
  lockfileVersion: fields.lockfileVersion,
package/index.d.ts CHANGED
@@ -77,8 +77,10 @@ declare const ImporterDependency_base: Schema.Class<ImporterDependency, Schema.S
77
77
  * comparable across refs and printable. pnpm records `{ specifier, version }`
78
78
  * per importer dependency, and the parser splits pnpm's peer-disambiguation
79
79
  * suffix off into `peerSuffix`, so `version` is always the plain version — or
80
- * a non-registry resolution (`link:../utils`, `file:...`), passed through
81
- * verbatim. bun and npm record resolved versions on their package entries
80
+ * a non-registry resolution (`link:../utils`, `file:...`). A `file:`
81
+ * resolution is suffixed like a registry version when the package declares
82
+ * peers and is split the same way; a `link:` is never suffixed and passes
83
+ * through verbatim. bun and npm record resolved versions on their package entries
82
84
  * instead, so for those formats `version` is absent and a consumer joins by
83
85
  * `name` against `Lockfile.packages`.
84
86
  * - `peerSuffix` — pnpm's peer-disambiguation context: the raw parenthesized
@@ -388,9 +390,10 @@ declare const LockfileFramingError_base: Schema.Class<LockfileFramingError, Sche
388
390
  * lockfile document could be located in it.
389
391
  *
390
392
  * @remarks
391
- * `pnpm-lock.yaml` is a YAML **stream**. pnpm 11 writes a
392
- * config-dependencies preamble document ahead of the lockfile whenever the
393
- * workspace uses `configDependencies`, so the file holds two documents. The
393
+ * `pnpm-lock.yaml` is a YAML **stream**. pnpm 11 and 12 write an env
394
+ * preamble document ahead of the lockfile whenever the workspace declares
395
+ * `configDependencies` or `devEngines.packageManager`, so the file holds two
396
+ * documents. The
394
397
  * lockfile is the last one — pnpm composes the preamble as a prefix — and a
395
398
  * parser that reads only the first document gets the preamble: a document
396
399
  * that *validates*, and yields a lockfile with an empty workspace. This error
@@ -399,20 +402,24 @@ declare const LockfileFramingError_base: Schema.Class<LockfileFramingError, Sche
399
402
  * - `format` — which format was being parsed.
400
403
  * - `documents` — how many YAML documents the stream carried.
401
404
  * - `reason`:
402
- * - `"noLockfileDocument"` — the stream carries no lockfile document. An
403
- * env-only `pnpm-lock.yaml` (a config-dependencies preamble and nothing
404
- * after it) reads this way, as does empty content. pnpm itself treats
405
- * such a file as having no lockfile.
405
+ * - `"noLockfileDocument"` — the stream carries no lockfile document:
406
+ * empty content, or a pnpm stream whose lockfile position is empty. That
407
+ * includes an env preamble followed by an empty document unless the
408
+ * caller passes `configOnly`: pnpm writes those same bytes for a
409
+ * config-dependency-only workspace with no root `package.json` *and* for
410
+ * a workspace whose first install failed after its config dependencies
411
+ * were installed, so only the caller can say which it is. With
412
+ * `configOnly`, `Lockfile.parse` reads that stream as an empty lockfile
413
+ * versioned by the preamble; an empty lockfile position with no preamble
414
+ * fails either way.
406
415
  * - `"noImporters"` — the located document declares no importers, so it
407
416
  * describes no workspace. pnpm always records at least the root importer.
408
417
  * - `"unexpectedDocuments"` — the stream carries more documents than the
409
418
  * format's framing defines: several in a format that defines none
410
- * (yarn), or more than the env preamble and the lockfile when reading a
411
- * pnpm env preamble. Rather than silently picking one, parsing refuses to
412
- * guess. For pnpm the limit is the env reader's alone:
413
- * `PnpmEnvLockfile` enforces at most two documents, while
414
- * `Lockfile.parse` reads the last document of a stream of any count and
415
- * never fails with this reason.
419
+ * (yarn), or more than the env preamble and the lockfile in a pnpm
420
+ * stream. Rather than silently picking one, parsing refuses to guess.
421
+ * `Lockfile.parse` and `PnpmEnvLockfile` share one pnpm splitter, so
422
+ * both enforce the same at-most-two limit.
416
423
  *
417
424
  * It carries typed fields rather than a `cause`: unlike
418
425
  * {@link LockfileParseError}, there is no underlying engine failure to wrap —
@@ -462,7 +469,14 @@ export declare class Lockfile extends Lockfile_base {
462
469
  *
463
470
  * @param content - The lockfile text (this package does no IO; the caller
464
471
  * reads the file).
465
- * @param options - The lockfile format to parse as.
472
+ * @param options - `format` is the lockfile format to parse as.
473
+ * `configOnly` is the caller's assertion that the workspace has **no root
474
+ * `package.json`**, so a pnpm env preamble followed by an empty main
475
+ * document is a config-dependency-only workspace and reads as an empty
476
+ * lockfile versioned by the preamble. Without it that stream fails
477
+ * {@link LockfileFramingError} with `noLockfileDocument`, because the same
478
+ * bytes are also what an interrupted first install leaves behind. It
479
+ * loosens nothing else, and is ignored for non-pnpm formats.
466
480
  * @returns An `Effect` succeeding with the {@link Lockfile}, or failing
467
481
  * with {@link LockfileParseError} (malformed text or the wrong shape) or
468
482
  * {@link LockfileFramingError} (the text parsed, but no lockfile document
@@ -471,6 +485,7 @@ export declare class Lockfile extends Lockfile_base {
471
485
  */
472
486
  static readonly parse: (content: string, options: {
473
487
  readonly format: LockfileFormat;
488
+ readonly configOnly?: boolean | undefined;
474
489
  }) => Effect.Effect<Lockfile, LockfileFramingError | LockfileParseError, never>;
475
490
  /**
476
491
  * Rewrite pnpm importer-path names to real package names — the explicit
package/internal/bun.js CHANGED
@@ -1,7 +1,7 @@
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, peerDeclarations, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
4
+ import { extractWorkspaceDeps, importerDependencies, peerDeclarations, splitNameVersion, syntaxFailure, toIntegrityHash, validationFailure } from "./shared.js";
5
5
  import { Effect, Exit, Schema } from "effect";
6
6
  import { Jsonc } from "@effected/jsonc";
7
7
 
@@ -149,10 +149,9 @@ const toFields = (raw) => Effect.gen(function* () {
149
149
  if (tuple.length < 1) continue;
150
150
  const first = tuple[0];
151
151
  if (typeof first !== "string") continue;
152
- const atIdx = first.lastIndexOf("@");
153
- if (atIdx <= 0) continue;
154
- const name = first.slice(0, atIdx);
155
- const version = first.slice(atIdx + 1);
152
+ const split = splitNameVersion(first);
153
+ if (split === void 0) continue;
154
+ const { name, version } = split;
156
155
  if (workspaceNames.has(name)) continue;
157
156
  const integrity = yield* toIntegrityHash(tuple.length >= 4 && typeof tuple[3] === "string" ? tuple[3] : void 0);
158
157
  const info = tuple.length >= 3 ? readBunPackageInfo(tuple[2]) : void 0;
@@ -13,41 +13,59 @@ import { Yaml } from "@effected/yaml";
13
13
  */
14
14
  const isEmptyDocument = (document) => document === null || document === void 0;
15
15
  /**
16
- * Select the lockfile document from a `pnpm-lock.yaml` YAML stream.
16
+ * The number of documents pnpm's env-lockfile writer can compose: the env
17
+ * preamble and the main lockfile. See {@link splitPnpmStream}.
17
18
  *
18
- * @remarks
19
- * pnpm 11 writes `pnpm-lock.yaml` as **up to two YAML documents** when the
20
- * workspace uses `configDependencies`: an optional config-dependencies
21
- * ("env") preamble, then the lockfile proper. The rule for picking the right
22
- * one is **deterministic, not a heuristic** — it is pnpm's own writer
23
- * contract. `writeEnvLockfile` emits `${env}---${main}`, composing the
24
- * preamble as a *prefix*, and `extractMainDocument` reads back everything
25
- * after the first separator. So the preamble is always first and the lockfile
26
- * is always **last**.
19
+ * @internal
20
+ */
21
+ const MAX_PNPM_DOCUMENTS = 2;
22
+ /**
23
+ * Split a `pnpm-lock.yaml` YAML stream into its env preamble and its main
24
+ * lockfile document. This is the one place pnpm's document framing is
25
+ * decided: `Lockfile.parse` projects the main document out of it and
26
+ * `PnpmEnvLockfile` the preamble, so the two readers cannot disagree about
27
+ * which document is which or how many a stream may carry.
27
28
  *
28
- * Both documents declare `lockfileVersion`, `importers` and `packages`, so
29
- * they are not told apart by which keys they carry — only by position. That
30
- * is exactly why the previous single-document assumption failed *silently*:
31
- * the preamble validates against the pnpm schema, yielding a `Lockfile`
32
- * reporting one package and an empty workspace instead of an error.
29
+ * @remarks
30
+ * pnpm 11 and 12 write `pnpm-lock.yaml` as **up to two YAML documents** when
31
+ * the workspace declares `configDependencies` or `devEngines.packageManager`:
32
+ * an env preamble, then the lockfile proper. The rule for telling them apart
33
+ * is **deterministic, not a heuristic**. It is pnpm's own writer contract:
34
+ * `writeEnvLockfile` emits `${env}---${main}`, composing the preamble as a
35
+ * *prefix*, so the preamble is the **first** of two documents and the
36
+ * lockfile is always the **last**. Both documents declare `lockfileVersion`,
37
+ * `importers` and `packages`, so position is the only discriminator. Content
38
+ * cannot tell them apart, which is how a single-document parse once read the
39
+ * preamble as the lockfile and reported an empty workspace.
33
40
  *
34
- * An env-only lockfile (`---` preamble `---` and nothing after it, which
35
- * pnpm writes when there is no main lockfile yet) has an **empty** trailing
36
- * document. pnpm reads that as "no lockfile"; so do we, through the typed
37
- * framing failure — never by silently falling back to the preamble.
41
+ * - One document: no preamble; that document is the lockfile.
42
+ * - Two documents: the first is the preamble, the second the lockfile.
43
+ * - More than two: outside the writer contract, so no position identifies
44
+ * either document. Rather than guess (the preamble feeds integrity
45
+ * verification, where a guess is a trust decision), it fails through the
46
+ * typed framing channel with `unexpectedDocuments`.
38
47
  *
39
- * It takes the last document of a stream of **any** count and never fails
40
- * with `unexpectedDocuments`; only {@link selectPnpmEnvDocument}, the env
41
- * reader's selector, enforces the at-most-two limit.
48
+ * An empty document composes to `null` and occupies its position as
49
+ * `undefined`. pnpm writes an empty *main* document after a preamble both for
50
+ * a workspace with no root `package.json` and only `configDependencies`, and
51
+ * for a workspace whose first install failed after its config dependencies
52
+ * were installed (measured against pnpm 11.28.0 and 12.7.0; the bytes are
53
+ * identical). Whether that is a lockfile is the caller's decision, not the
54
+ * splitter's.
42
55
  *
43
56
  * @internal
44
57
  */
45
- const selectPnpmDocument = (content) => Effect.gen(function* () {
58
+ const splitPnpmStream = (content) => Effect.gen(function* () {
46
59
  const documents = yield* Yaml.parseAll(content).pipe(Effect.mapError(syntaxFailure));
47
- const document = documents.at(-1);
48
- if (documents.length === 0 || isEmptyDocument(document)) return yield* Effect.fail(framingFailure("noLockfileDocument", documents.length));
49
- return {
50
- document,
60
+ if (documents.length > MAX_PNPM_DOCUMENTS) return yield* Effect.fail(framingFailure("unexpectedDocuments", documents.length));
61
+ const present = (document) => isEmptyDocument(document) ? void 0 : document;
62
+ return documents.length === MAX_PNPM_DOCUMENTS ? {
63
+ preamble: present(documents[0]),
64
+ main: present(documents[1]),
65
+ documents: documents.length
66
+ } : {
67
+ preamble: void 0,
68
+ main: present(documents[0]),
51
69
  documents: documents.length
52
70
  };
53
71
  });
@@ -75,46 +93,6 @@ const selectSoleDocument = (content) => Effect.gen(function* () {
75
93
  documents: documents.length
76
94
  };
77
95
  });
78
- /**
79
- * The number of documents pnpm's env-lockfile writer can compose: the env
80
- * preamble and the main lockfile. See {@link selectPnpmEnvDocument}.
81
- *
82
- * @internal
83
- */
84
- const MAX_PNPM_DOCUMENTS = 2;
85
- /**
86
- * Select the env ("preamble") document from a `pnpm-lock.yaml` YAML stream,
87
- * or `undefined` when the stream carries none.
88
- *
89
- * @remarks
90
- * The same position rule as {@link selectPnpmDocument}, read from the other
91
- * end: pnpm's `writeEnvLockfile` composes `${env}---${main}`, so the preamble
92
- * is the **first** of exactly two documents. A single-document stream has no
93
- * preamble — that is how pnpm writes a lockfile for a workspace that declares
94
- * neither `configDependencies` nor `devEngines.packageManager` — and neither
95
- * does an empty one. An empty first document is no preamble either: there is
96
- * nothing in it to read.
97
- *
98
- * A stream carrying **more** than two documents is outside the writer
99
- * contract, so no position identifies the preamble in it. Rather than guess —
100
- * this selection feeds integrity verification, where a guess is a trust
101
- * decision — it fails through the typed framing channel. That limit is this
102
- * selector's alone: {@link selectPnpmDocument}, behind `Lockfile.parse`, takes
103
- * the last document of a stream of any count.
104
- *
105
- * @internal
106
- */
107
- const selectPnpmEnvDocument = (content) => Effect.gen(function* () {
108
- const documents = yield* Yaml.parseAll(content).pipe(Effect.mapError(syntaxFailure));
109
- if (documents.length > MAX_PNPM_DOCUMENTS) return yield* Effect.fail(framingFailure("unexpectedDocuments", documents.length));
110
- if (documents.length < MAX_PNPM_DOCUMENTS) return void 0;
111
- const document = documents.at(0);
112
- if (isEmptyDocument(document)) return void 0;
113
- return {
114
- document,
115
- documents: documents.length
116
- };
117
- });
118
96
 
119
97
  //#endregion
120
- export { selectPnpmDocument, selectPnpmEnvDocument, selectSoleDocument };
98
+ export { selectSoleDocument, splitPnpmStream };
package/internal/pnpm.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { LockfileImporter } from "../LockfileImporter.js";
2
2
  import { ResolvedPackage } from "../ResolvedPackage.js";
3
- import { extractWorkspaceDeps, framingFailure, gatePnpmVersion, importerDependencies, peerDeclarations, splitPeerSuffix, toIntegrityHash, validationFailure } from "./shared.js";
3
+ import { extractWorkspaceDeps, framingFailure, gatePnpmVersion, importerDependencies, peerDeclarations, splitNameVersion, splitPeerSuffix, toIntegrityHash, validationFailure } from "./shared.js";
4
4
  import { PnpmExtension } from "../PnpmExtension.js";
5
- import { selectPnpmDocument } from "./documents.js";
5
+ import { splitPnpmStream } from "./documents.js";
6
6
  import { Effect, Schema } from "effect";
7
7
 
8
8
  //#region src/internal/pnpm.ts
@@ -46,10 +46,22 @@ const PnpmLockfileRaw = Schema.Struct({
46
46
  *
47
47
  * `pnpm-lock.yaml` is a YAML *stream*, not a single document: a workspace
48
48
  * using `configDependencies` gets a config-dependencies preamble document
49
- * ahead of the lockfile. {@link selectPnpmDocument} locates the lockfile
50
- * deterministically (it is the last document); a stream carrying no lockfile
51
- * document fails through the typed framing channel rather than silently
52
- * reporting the preamble as an empty workspace.
49
+ * ahead of the lockfile. {@link splitPnpmStream} locates the lockfile
50
+ * deterministically (it is the last document); the preamble is never read as
51
+ * the lockfile.
52
+ *
53
+ * An empty main document after a preamble is **ambiguous**: pnpm 11 and 12
54
+ * write exactly those bytes both for a config-dependency-only workspace with
55
+ * no root `package.json` and for a workspace whose first install failed after
56
+ * its config dependencies were installed. The bytes cannot tell the two apart,
57
+ * so the parser does not decide: only when the caller asserts `configOnly`
58
+ * (it knows the root has no `package.json`) does the stream read as an empty
59
+ * lockfile — no packages, importers or workspace edges, versioned by the
60
+ * preamble's `lockfileVersion` after the preamble passes the same version
61
+ * gate. The preamble's own packages are config dependencies, not the
62
+ * workspace's, so none of them is reported. Without the assertion, and always
63
+ * when there is no preamble to vouch for the stream, an empty main document
64
+ * fails `noLockfileDocument`. The flag loosens nothing else.
53
65
  *
54
66
  * Workspace packages are keyed by importer *path* with version `"0.0.0"`;
55
67
  * `Lockfile#withImporterNames` is the explicit second stage that rewrites
@@ -57,8 +69,18 @@ const PnpmLockfileRaw = Schema.Struct({
57
69
  *
58
70
  * @internal
59
71
  */
60
- const parsePnpm = (content) => Effect.gen(function* () {
61
- const { document, documents } = yield* selectPnpmDocument(content);
72
+ const parsePnpm = (content, configOnly) => Effect.gen(function* () {
73
+ const { preamble, main: document, documents } = yield* splitPnpmStream(content);
74
+ if (document === void 0) {
75
+ if (preamble === void 0 || !configOnly) return yield* Effect.fail(framingFailure("noLockfileDocument", documents));
76
+ return {
77
+ lockfileVersion: yield* gatePnpmVersion(preamble),
78
+ packages: [],
79
+ workspaceDependencies: [],
80
+ importers: [],
81
+ extension: PnpmExtension.make({})
82
+ };
83
+ }
62
84
  yield* gatePnpmVersion(document);
63
85
  const validated = yield* Schema.decodeUnknownEffect(PnpmLockfileRaw)(document).pipe(Effect.mapError(validationFailure));
64
86
  if (Object.keys(validated.importers).length === 0) return yield* Effect.fail(framingFailure("noImporters", documents));
@@ -291,11 +313,9 @@ const toFields = (raw) => Effect.gen(function* () {
291
313
  }));
292
314
  }
293
315
  const emit = (instanceId, meta, edges) => Effect.gen(function* () {
294
- const { plain: bare } = splitPeerSuffix(instanceId);
295
- const atIndex = bare.lastIndexOf("@");
296
- if (atIndex <= 0) return;
297
- const name = bare.slice(0, atIndex);
298
- const version = bare.slice(atIndex + 1);
316
+ const split = splitNameVersion(splitPeerSuffix(instanceId).plain);
317
+ if (split === void 0) return;
318
+ const { name, version } = split;
299
319
  const integrity = yield* toIntegrityHash(meta?.resolution?.integrity);
300
320
  emitted.add(instanceId);
301
321
  packages.push(ResolvedPackage.make({
@@ -1,6 +1,6 @@
1
1
  import { ConfigDependencyLock } from "../ConfigDependencyLock.js";
2
2
  import { gatePnpmVersion, validationFailure } from "./shared.js";
3
- import { selectPnpmEnvDocument } from "./documents.js";
3
+ import { splitPnpmStream } from "./documents.js";
4
4
  import { PackageManagerLock } from "../PackageManagerLock.js";
5
5
  import { Effect, Schema } from "effect";
6
6
  import { IntegrityHash } from "@effected/npm";
@@ -59,10 +59,10 @@ const recordedIntegrity = (raw, subject, name, version) => version === "" ? Effe
59
59
  * cannot disagree about which document is the preamble or what shape it has.
60
60
  */
61
61
  const decodePreamble = (content) => Effect.gen(function* () {
62
- const selected = yield* selectPnpmEnvDocument(content);
63
- if (selected === void 0) return void 0;
64
- yield* gatePnpmVersion(selected.document);
65
- return yield* Schema.decodeUnknownEffect(PnpmEnvRaw)(selected.document).pipe(Effect.mapError(validationFailure));
62
+ const { preamble } = yield* splitPnpmStream(content);
63
+ if (preamble === void 0) return void 0;
64
+ yield* gatePnpmVersion(preamble);
65
+ return yield* Schema.decodeUnknownEffect(PnpmEnvRaw)(preamble).pipe(Effect.mapError(validationFailure));
66
66
  });
67
67
  /**
68
68
  * Read the package manager pinned by a `pnpm-lock.yaml`'s env preamble.
@@ -42,26 +42,66 @@ const toIntegrityHash = (raw) => {
42
42
  };
43
43
  const decodeSpecifier = Schema.decodeUnknownExit(DependencySpecifier.FromString);
44
44
  /**
45
- * Split pnpm's peer-disambiguation suffix off a recorded string: everything
46
- * from the first `(` on is suffix, since package names and versions never
47
- * contain `(`. Handles both shapes pnpm records — a bare version
48
- * (`"1.0.0(effect@4.0.0)"`, importer entries) and a `name@version` snapshot key
49
- * (`"fdir@6.5.0(picomatch@4.0.4)"`). A string with no `(` — including
50
- * `link:...` / `file:...` resolutions — passes through untouched. So does a
51
- * protocol-bearing string (a `:` ahead of the first `(`), where a parenthesis
52
- * is path or URL text rather than a peer suffix: pnpm attaches suffixes to
53
- * registry versions only. This is the single stripping implementation — do not
54
- * hand-roll an `indexOf("(")` split elsewhere.
45
+ * Split pnpm's peer-disambiguation suffix off a recorded string: the suffix is
46
+ * the maximal TRAILING run of balanced parenthesized groups, found by scanning
47
+ * back from the end, which is `@pnpm/dependency-path`'s own
48
+ * `indexOfDepPathSuffix` rule. Handles both shapes pnpm records — a bare
49
+ * version (`"1.0.0(effect@4.0.0)"`, importer entries) and a `name@version`
50
+ * snapshot key (`"fdir@6.5.0(picomatch@4.0.4)"`) — and nested chains
51
+ * (`"(a@1(b@2))(c@3)"`) as one suffix.
52
+ *
53
+ * pnpm suffixes a `file:` resolution exactly as it suffixes a registry
54
+ * version whenever the package declares peers, directory and tarball alike
55
+ * (`file:vendor/lib(react@18.3.1)`, measured against pnpm 12.6.0), so the rule
56
+ * applies to protocol versions too. The one exception is `link:`, which pnpm
57
+ * never suffixes, even when the target declares peers: a trailing group there
58
+ * is path text and the string passes through untouched.
59
+ *
60
+ * A parenthesis INSIDE a path is followed by more path, so it is never part of
61
+ * the trailing run (`file:vendor/a(b)/c(react@1.0.0)` splits after `c`). A path
62
+ * that itself ENDS in a group is ambiguous, and it is read the way pnpm reads
63
+ * it: pnpm writes `file:vendor/paren(lib)`'s `packages:` key as
64
+ * `parenlib@file:vendor/paren`, so taking the group as suffix is what keeps the
65
+ * snapshot joined to its own metadata. A string that is nothing but groups
66
+ * yields an empty `plain`, and an unbalanced one passes through untouched.
67
+ *
68
+ * This is the single stripping implementation — do not hand-roll a
69
+ * parenthesis split elsewhere.
55
70
  *
56
71
  * @internal
57
72
  */
58
73
  const splitPeerSuffix = (raw) => {
59
- const parenIndex = raw.indexOf("(");
60
- if (parenIndex === -1) return { plain: raw };
61
- if (raw.lastIndexOf(":", parenIndex) !== -1) return { plain: raw };
74
+ if (raw.startsWith("link:") || !raw.endsWith(")")) return { plain: raw };
75
+ let open = 1;
76
+ for (let i = raw.length - 2; i >= 0; i--) {
77
+ const char = raw[i];
78
+ if (char === "(") open--;
79
+ else if (char === ")") open++;
80
+ else if (open === 0) return {
81
+ plain: raw.slice(0, i + 1),
82
+ peerSuffix: raw.slice(i + 1)
83
+ };
84
+ }
85
+ return open === 0 ? {
86
+ plain: "",
87
+ peerSuffix: raw
88
+ } : { plain: raw };
89
+ };
90
+ /**
91
+ * Split a `name@version` key at the separator: the first `@` after a scoped
92
+ * name's leading one. A package name holds no other `@`, while the version
93
+ * part may (`file:../@scope/lib`), so the LAST `@` is the wrong boundary.
94
+ * Returns `undefined` for a key with no separator after its first character
95
+ * (`"@"`, `"@scope/"`, a bare name).
96
+ *
97
+ * @internal
98
+ */
99
+ const splitNameVersion = (key) => {
100
+ const at = key.indexOf("@", 1);
101
+ if (at === -1) return void 0;
62
102
  return {
63
- plain: raw.slice(0, parenIndex),
64
- peerSuffix: raw.slice(parenIndex)
103
+ name: key.slice(0, at),
104
+ version: key.slice(at + 1)
65
105
  };
66
106
  };
67
107
  /**
@@ -204,9 +244,13 @@ const PnpmVersionProbe = Schema.Struct({ lockfileVersion: Schema.Union([Schema.S
204
244
  * version: decode {@link PnpmVersionProbe}, then {@link requireLockfileVersion}.
205
245
  * Call it BEFORE the shape decode, for the reason the probe documents.
206
246
  *
247
+ * Succeeds with the gated version as the model spells it (`String` of the
248
+ * recorded value), for a caller that reports a version without decoding the
249
+ * rest of the document.
250
+ *
207
251
  * @internal
208
252
  */
209
- const gatePnpmVersion = (document) => Schema.decodeUnknownEffect(PnpmVersionProbe)(document).pipe(Effect.mapError(validationFailure), Effect.flatMap((probe) => requireLockfileVersion("pnpm", probe.lockfileVersion)));
253
+ const gatePnpmVersion = (document) => Schema.decodeUnknownEffect(PnpmVersionProbe)(document).pipe(Effect.mapError(validationFailure), Effect.flatMap((probe) => requireLockfileVersion("pnpm", probe.lockfileVersion).pipe(Effect.as(String(probe.lockfileVersion)))));
210
254
  /** @internal */
211
255
  const syntaxFailure = (cause) => ({
212
256
  stage: "syntax",
@@ -255,4 +299,4 @@ const extractWorkspaceDeps = (workspaces, workspaceNames) => {
255
299
  };
256
300
 
257
301
  //#endregion
258
- export { DEP_TYPES, MINIMUM_LOCKFILE_VERSION, extractWorkspaceDeps, framingFailure, gatePnpmVersion, importerDependencies, isWorkspaceSpecifier, peerDeclarations, requireLockfileVersion, splitPeerSuffix, syntaxFailure, toIntegrityHash, validationFailure };
302
+ export { DEP_TYPES, MINIMUM_LOCKFILE_VERSION, extractWorkspaceDeps, framingFailure, gatePnpmVersion, importerDependencies, isWorkspaceSpecifier, peerDeclarations, requireLockfileVersion, splitNameVersion, splitPeerSuffix, syntaxFailure, toIntegrityHash, validationFailure };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/lockfiles",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@effected/jsonc": "^0.13.0",
44
- "@effected/npm": "^0.17.0",
44
+ "@effected/npm": "^0.18.0",
45
45
  "@effected/semver": "^0.9.0",
46
46
  "@effected/yaml": "^0.17.0",
47
47
  "effect": "4.0.0-rc.117"