@effected/lockfiles 0.5.1 → 0.6.1
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 +6 -1
- package/LockfileFormat.js +5 -1
- package/README.md +54 -4
- package/ResolvedPackage.js +56 -1
- package/UnsupportedLockfileVersion.js +35 -0
- package/index.d.ts +126 -2
- package/index.js +2 -1
- package/internal/bun.js +90 -6
- package/internal/npm.js +104 -13
- package/internal/pnpm.js +186 -9
- package/internal/shared.js +74 -1
- package/internal/yarn.js +55 -2
- package/package.json +1 -1
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` (
|
|
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
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
Lockfile parsing for [Effect](https://effect.website) v4: bun (`bun.lock`), npm (`package-lock.json`
|
|
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
|
|
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
|
|
package/ResolvedPackage.js
CHANGED
|
@@ -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` (
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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))
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
|
82
|
-
const name = key.slice(
|
|
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({
|
|
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,142 @@ 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
|
+
/** The specifier protocol pnpm resolves through the workspace itself. @internal */
|
|
139
|
+
const WORKSPACE_PREFIX = "workspace:";
|
|
140
|
+
/**
|
|
141
|
+
* The importer a `link:` target lands INSIDE, longest path first.
|
|
142
|
+
*
|
|
143
|
+
* pnpm's `publishConfig.linkDirectory` makes a workspace link point at the
|
|
144
|
+
* package's publish directory rather than its root — `link:../bundler/dist/dev/pkg`
|
|
145
|
+
* where `packages/bundler` is the importer — so the target itself is a build
|
|
146
|
+
* output that is no importer and never will be. The owning importer is the
|
|
147
|
+
* longest ancestor of the target that IS one; longest-first so a nested importer
|
|
148
|
+
* beats its parent.
|
|
149
|
+
*
|
|
150
|
+
* The root importer (`"."`) is deliberately unreachable here: it is an ancestor
|
|
151
|
+
* of every path in the workspace, so admitting it would resolve every stray link
|
|
152
|
+
* to the root rather than leaving it unnamed.
|
|
153
|
+
*
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
const owningImporter = (target, importerPaths) => {
|
|
157
|
+
const segments = target.split("/");
|
|
158
|
+
for (let end = segments.length - 1; end > 0; end--) {
|
|
159
|
+
const candidate = segments.slice(0, end).join("/");
|
|
160
|
+
if (importerPaths.has(candidate)) return candidate;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Resolve one importer's outgoing edges. pnpm records `{ specifier, version }`
|
|
165
|
+
* per importer dependency, where the version is either a registry version (the
|
|
166
|
+
* instance-id composition {@link resolveEdges} performs) or a `link:` target
|
|
167
|
+
* naming another importer — normalized against this importer's own path. Both
|
|
168
|
+
* are checked against the lockfile's instance ids before being emitted.
|
|
169
|
+
*
|
|
170
|
+
* A `link:` target that is no importer gets one further chance, and only under
|
|
171
|
+
* a `workspace:` SPECIFIER: pnpm resolved that edge through the workspace, so
|
|
172
|
+
* the target directory is known to belong to a workspace package, and
|
|
173
|
+
* {@link owningImporter} names it. The specifier is what makes this a
|
|
174
|
+
* resolution rather than a guess — a hand-written `link:../vendor/stub`
|
|
175
|
+
* specifier names a directory pnpm never claimed was a workspace package, and
|
|
176
|
+
* attributing it to whichever importer happens to contain it would answer with
|
|
177
|
+
* the wrong package's peers instead of admitting it could not name the edge.
|
|
178
|
+
*
|
|
179
|
+
* The snapshot path ({@link resolveEdges}) deliberately does NOT do this: a
|
|
180
|
+
* snapshot body records `name: link:<path>` with no specifier beside it, so the
|
|
181
|
+
* evidence this rule turns on is not available there.
|
|
182
|
+
*
|
|
183
|
+
* @internal
|
|
184
|
+
*/
|
|
185
|
+
const resolveImporterEdges = (importer, importerPath, instanceIds, importerPaths) => {
|
|
186
|
+
const edges = /* @__PURE__ */ new Map();
|
|
187
|
+
const unnameable = /* @__PURE__ */ new Set();
|
|
188
|
+
for (const group of importerDepGroups(importer)) {
|
|
189
|
+
if (!group) continue;
|
|
190
|
+
for (const [name, info] of Object.entries(group)) {
|
|
191
|
+
if (name === "") continue;
|
|
192
|
+
if (info.version.startsWith(LINK_PREFIX)) {
|
|
193
|
+
const target = resolveLinkTarget(importerPath, info.version.slice(5));
|
|
194
|
+
const owner = target === void 0 ? void 0 : instanceIds.has(target) ? target : info.specifier.startsWith(WORKSPACE_PREFIX) ? owningImporter(target, importerPaths) : void 0;
|
|
195
|
+
if (owner !== void 0) edges.set(name, owner);
|
|
196
|
+
else unnameable.add(name);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const bare = `${name}@${info.version}`;
|
|
200
|
+
if (instanceIds.has(bare)) edges.set(name, bare);
|
|
201
|
+
else unnameable.add(name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
resolved: Object.fromEntries(edges),
|
|
206
|
+
unresolvedEdges: [...unnameable].sort()
|
|
207
|
+
};
|
|
208
|
+
};
|
|
55
209
|
const toVersionMap = (deps) => {
|
|
56
210
|
if (!deps) return void 0;
|
|
57
211
|
return Object.fromEntries(Object.entries(deps).map(([name, info]) => [name, info.specifier]));
|
|
@@ -92,28 +246,51 @@ const toFields = (raw) => Effect.gen(function* () {
|
|
|
92
246
|
}
|
|
93
247
|
for (const path of Object.keys(raw.importers)) if (path !== "." && path !== "") workspaceNames.add(path);
|
|
94
248
|
const packages = [];
|
|
95
|
-
|
|
249
|
+
const snapshots = raw.snapshots;
|
|
250
|
+
const instanceIds = /* @__PURE__ */ new Set([
|
|
251
|
+
...snapshots ? Object.keys(snapshots) : [],
|
|
252
|
+
...raw.packages ? Object.keys(raw.packages) : [],
|
|
253
|
+
...Object.keys(raw.importers)
|
|
254
|
+
]);
|
|
255
|
+
const importerPaths = new Set(Object.keys(raw.importers).filter((path) => path !== "." && path !== ""));
|
|
256
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
257
|
+
for (const [importerPath, importer] of Object.entries(raw.importers)) {
|
|
96
258
|
if (importerPath === "." || importerPath === "") continue;
|
|
97
259
|
packages.push(ResolvedPackage.make({
|
|
98
260
|
name: importerPath,
|
|
99
261
|
version: "0.0.0",
|
|
262
|
+
instanceId: importerPath,
|
|
100
263
|
isWorkspace: true,
|
|
101
|
-
relativePath: importerPath
|
|
264
|
+
relativePath: importerPath,
|
|
265
|
+
...resolveImporterEdges(importer, importerPath, instanceIds, importerPaths)
|
|
102
266
|
}));
|
|
103
267
|
}
|
|
104
|
-
|
|
105
|
-
const { plain: bare } = splitPeerSuffix(
|
|
268
|
+
const emit = (instanceId, meta, edges) => Effect.gen(function* () {
|
|
269
|
+
const { plain: bare } = splitPeerSuffix(instanceId);
|
|
106
270
|
const atIndex = bare.lastIndexOf("@");
|
|
107
|
-
if (atIndex <= 0)
|
|
271
|
+
if (atIndex <= 0) return;
|
|
108
272
|
const name = bare.slice(0, atIndex);
|
|
109
273
|
const version = bare.slice(atIndex + 1);
|
|
110
|
-
const integrity = yield* toIntegrityHash(
|
|
274
|
+
const integrity = yield* toIntegrityHash(meta?.resolution?.integrity);
|
|
275
|
+
emitted.add(instanceId);
|
|
111
276
|
packages.push(ResolvedPackage.make({
|
|
112
277
|
name,
|
|
113
278
|
version,
|
|
279
|
+
instanceId,
|
|
114
280
|
...integrity !== void 0 ? { integrity } : {},
|
|
115
|
-
isWorkspace: false
|
|
281
|
+
isWorkspace: false,
|
|
282
|
+
...peerDeclarations(meta?.peerDependencies, meta?.peerDependenciesMeta),
|
|
283
|
+
...resolveEdges(edges, instanceIds)
|
|
116
284
|
}));
|
|
285
|
+
});
|
|
286
|
+
if (snapshots) for (const [key, snapshot] of Object.entries(snapshots)) {
|
|
287
|
+
const plain = splitPeerSuffix(key).plain;
|
|
288
|
+
yield* emit(key, raw.packages !== void 0 && Object.hasOwn(raw.packages, plain) ? raw.packages[plain] : void 0, [snapshot.dependencies, snapshot.optionalDependencies]);
|
|
289
|
+
}
|
|
290
|
+
const covered = new Set([...emitted].map((key) => splitPeerSuffix(key).plain));
|
|
291
|
+
if (raw.packages) for (const [key, pkg] of Object.entries(raw.packages)) {
|
|
292
|
+
if (covered.has(key)) continue;
|
|
293
|
+
yield* emit(key, pkg, [pkg.dependencies, pkg.optionalDependencies]);
|
|
117
294
|
}
|
|
118
295
|
const workspaceDependencies = extractWorkspaceDeps(workspaceEntries, workspaceNames);
|
|
119
296
|
const extension = PnpmExtension.make({
|
package/internal/shared.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.6.1",
|
|
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": [
|