@effected/workspaces 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LockfileReader.js +5 -1
- package/PeerCheck.js +449 -36
- package/README.md +20 -5
- package/WorkspaceDiscovery.js +2 -1
- package/WorkspaceSnapshots.js +3 -3
- package/WorkspaceStateSnapshot.js +47 -30
- package/WorkspacesSync.js +11 -1
- package/index.d.ts +904 -742
- package/package.json +4 -4
package/LockfileReader.js
CHANGED
|
@@ -69,7 +69,11 @@ var LockfileReader = class LockfileReader extends Context.Service()("@effected/w
|
|
|
69
69
|
format,
|
|
70
70
|
cause
|
|
71
71
|
})));
|
|
72
|
-
const
|
|
72
|
+
const configOnly = format === "pnpm" && !(yield* fs.exists(path.join(root, "package.json")).pipe(Effect.orElseSucceed(() => true)));
|
|
73
|
+
const lockfile = yield* Lockfile.parse(content, {
|
|
74
|
+
format,
|
|
75
|
+
configOnly
|
|
76
|
+
});
|
|
73
77
|
if (format !== "pnpm") return lockfile;
|
|
74
78
|
const importers = lockfile.packages.filter((pkg) => pkg.isWorkspace && pkg.relativePath !== void 0);
|
|
75
79
|
const resolved = yield* Effect.forEach(importers, (pkg) => readName(path.join(root, pkg.relativePath, "package.json")).pipe(Effect.map((name) => [pkg.relativePath, name])), { concurrency: 10 });
|
package/PeerCheck.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { indexInstances, rootInstances } from "./internal/roots.js";
|
|
2
2
|
import { peerNameMatcher } from "./internal/peerPatterns.js";
|
|
3
|
-
import { Result, Schema } from "effect";
|
|
3
|
+
import { Option, Result, Schema } from "effect";
|
|
4
4
|
import { Range, SemVer } from "@effected/semver";
|
|
5
5
|
|
|
6
6
|
//#region src/PeerCheck.ts
|
|
@@ -79,6 +79,316 @@ const PEER_RESOLVING_FORMATS = /* @__PURE__ */ new Set([
|
|
|
79
79
|
/** @internal */
|
|
80
80
|
const supportsPeerResolution = (format) => PEER_RESOLVING_FORMATS.has(format);
|
|
81
81
|
/**
|
|
82
|
+
* The protocol pnpm records a workspace-directory resolution under, passed
|
|
83
|
+
* through verbatim on `ImporterDependency.version` by `@effected/lockfiles`.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
const LINK_PREFIX = "link:";
|
|
88
|
+
/**
|
|
89
|
+
* The workspace-relative directory a `link:` version names, resolved against
|
|
90
|
+
* the importer that recorded it.
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* `link:../a` in `packages/b` is `packages/a`. Deliberately pure string
|
|
94
|
+
* arithmetic rather than `node:path`: `link:` targets are spelled POSIX in a
|
|
95
|
+
* lockfile, and a Windows host would otherwise translate them to backslashes
|
|
96
|
+
* and stop matching the importer paths the same file uses.
|
|
97
|
+
*
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
const linkTargetPath = (importerPath, target) => {
|
|
101
|
+
const segments = importerPath === "." ? [] : importerPath.split("/");
|
|
102
|
+
const resolved = [];
|
|
103
|
+
for (const segment of [...segments, ...target.split("/")]) {
|
|
104
|
+
if (segment === "" || segment === ".") continue;
|
|
105
|
+
if (segment === "..") {
|
|
106
|
+
resolved.pop();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
resolved.push(segment);
|
|
110
|
+
}
|
|
111
|
+
return resolved.length === 0 ? "." : resolved.join("/");
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* An importer's walk roots with its `link:` targets seeded in.
|
|
115
|
+
*
|
|
116
|
+
* @remarks
|
|
117
|
+
* An importer with its own workspace row already reaches its link targets
|
|
118
|
+
* through that row's `resolved` edges. One without — the ROOT under pnpm —
|
|
119
|
+
* does not: the shared join composes `name@link:…`, matches no instance, and
|
|
120
|
+
* drops the dependency, so a covered target there would never be walked.
|
|
121
|
+
* Seeding the target rows here, rather than forking the shared join in
|
|
122
|
+
* `internal/roots.ts`, keeps `DuplicateCheck` on the join it was measured
|
|
123
|
+
* against while letting the peer walk judge the root's linked parents against
|
|
124
|
+
* the root's own provider context, as pnpm does (`linkdeep-root/`).
|
|
125
|
+
*
|
|
126
|
+
* An importer the shared join gave up on is rescued only when EVERY versioned
|
|
127
|
+
* dependency it has is a `link:` edge with a row: that failure was then the
|
|
128
|
+
* missing link seed and nothing else. Any other unjoinable dependency keeps the
|
|
129
|
+
* importer unresolved.
|
|
130
|
+
*
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
const withLinkRoots = (roots, links, dependencies) => {
|
|
134
|
+
const rows = links.flatMap((link) => link.row === void 0 ? [] : [link.row]);
|
|
135
|
+
if (roots?._tag === "own" || rows.length === 0) return roots;
|
|
136
|
+
if (roots === void 0) {
|
|
137
|
+
if (dependencies.filter((dep) => dep.version !== void 0).length !== rows.length) return void 0;
|
|
138
|
+
return {
|
|
139
|
+
_tag: "dependencies",
|
|
140
|
+
instances: rows
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
_tag: "dependencies",
|
|
145
|
+
instances: [...roots.instances, ...rows]
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* The workspace-relative directories a `link:` to `pkg` can name.
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* The package directory always, and — when `publishConfig.directory` is set
|
|
153
|
+
* and `publishConfig.linkDirectory` is not `false` — the publish directory
|
|
154
|
+
* inside it, because that is where pnpm links a workspace dependency then.
|
|
155
|
+
* Measured against pnpm 12.6.0, not assumed: `directory` alone records
|
|
156
|
+
* `link:../a/dist` (`linkdeep-directory/`, byte-identical with and without
|
|
157
|
+
* `linkDirectory: true`), and `linkDirectory: false` records `link:../a`
|
|
158
|
+
* (`linkdeep-directory-false/`). pnpm defaults `linkDirectory` to true.
|
|
159
|
+
*
|
|
160
|
+
* pnpm reads a publish-directory target's peers from the manifest AT that
|
|
161
|
+
* target — the built one — while the join reads the supplied source manifest
|
|
162
|
+
* and resolves `catalog:` ranges through the caller's catalogs. The two agree
|
|
163
|
+
* exactly when the build emits the peer ranges the source's specifiers
|
|
164
|
+
* resolve to, which is the contract a publish-directory build keeps.
|
|
165
|
+
*
|
|
166
|
+
* @internal
|
|
167
|
+
*/
|
|
168
|
+
const linkTargetsOf = (relativePath, pkg) => {
|
|
169
|
+
const directory = pkg.publishConfig?.directory;
|
|
170
|
+
if (directory === void 0 || pkg.publishConfig?.linkDirectory === false) return [relativePath];
|
|
171
|
+
const published = linkTargetPath(relativePath, directory);
|
|
172
|
+
return published === relativePath ? [relativePath] : [relativePath, published];
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* The dependency instances a joined parent's peers resolve against.
|
|
176
|
+
*
|
|
177
|
+
* @remarks
|
|
178
|
+
* **The importer's own dependency set, not the workspace's.** Measured
|
|
179
|
+
* against pnpm 12.5.1 and 12.6.0 on the `linkdeep*` probe workspace, one
|
|
180
|
+
* variable at a time: `react@18.3.1` as `packages/b`'s OWN dependency leaves
|
|
181
|
+
* the linked `probe-a`'s `react: ^18.0.0` peer satisfied (pnpm 12.5.1 reports
|
|
182
|
+
* the importer clean), the same version installed only by a SIBLING importer
|
|
183
|
+
* does not (`packages/b` reports it missing), and `react@17.0.2` of its own
|
|
184
|
+
* is a `bad` row carrying `foundVersion: "17.0.2"`. A workspace-wide
|
|
185
|
+
* name lookup — the obvious shortcut, since the instances are all in one
|
|
186
|
+
* lockfile — would have reported the sibling case clean, which pnpm does not.
|
|
187
|
+
*
|
|
188
|
+
* Both root shapes answer the same question: an importer with its own
|
|
189
|
+
* workspace row resolves peers against that row's recorded edges, and an
|
|
190
|
+
* importer without one (the root under pnpm, npm and bun) against the
|
|
191
|
+
* instances its dependency records joined to.
|
|
192
|
+
*
|
|
193
|
+
* @internal
|
|
194
|
+
*/
|
|
195
|
+
const providerContext = (roots, byId, links) => {
|
|
196
|
+
const context = /* @__PURE__ */ new Map();
|
|
197
|
+
if (roots._tag === "dependencies") {
|
|
198
|
+
for (const instance of roots.instances) context.set(instance.name, instance);
|
|
199
|
+
for (const link of links) if (link.row !== void 0) context.set(link.name, link.row);
|
|
200
|
+
return context;
|
|
201
|
+
}
|
|
202
|
+
for (const [name, instanceId] of Object.entries(roots.instance.resolved)) {
|
|
203
|
+
const provider = byId.get(instanceId);
|
|
204
|
+
if (provider !== void 0) context.set(name, provider);
|
|
205
|
+
}
|
|
206
|
+
return context;
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* The supplied manifest for a node the walk reached through a covered `link:`
|
|
210
|
+
* edge, or `undefined` for every other node.
|
|
211
|
+
*
|
|
212
|
+
* @remarks
|
|
213
|
+
* The importer's OWN node is deliberately excluded — {@link collect} only
|
|
214
|
+
* consults this for nodes with a non-empty chain. pnpm reports nothing for a
|
|
215
|
+
* linked package's own unsatisfied peers (it reports them through a consumer),
|
|
216
|
+
* so joining them onto the importer's own chain would emit rows the oracle
|
|
217
|
+
* does not have.
|
|
218
|
+
*
|
|
219
|
+
* @internal
|
|
220
|
+
*/
|
|
221
|
+
const joinedManifest = (join, instance) => {
|
|
222
|
+
if (instance.relativePath === void 0 || !join.joinable.has(instance.instanceId)) return void 0;
|
|
223
|
+
return join.manifests.get(instance.relativePath);
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* The peers a joined manifest marks optional.
|
|
227
|
+
*
|
|
228
|
+
* @remarks
|
|
229
|
+
* Read from the raw manifest record rather than from a typed model, because
|
|
230
|
+
* `WorkspacePackage` carries `peerDependenciesMeta` as it was written, exactly
|
|
231
|
+
* as the lockfile model carries its own. A malformed block yields no optional
|
|
232
|
+
* peers, which is the stricter answer: an unsatisfied peer is reported rather
|
|
233
|
+
* than silently excused.
|
|
234
|
+
*
|
|
235
|
+
* @internal
|
|
236
|
+
*/
|
|
237
|
+
const optionalPeers = (manifest) => {
|
|
238
|
+
const optional = /* @__PURE__ */ new Set();
|
|
239
|
+
const meta = manifest.manifestRecord.peerDependenciesMeta;
|
|
240
|
+
if (typeof meta !== "object" || meta === null) return optional;
|
|
241
|
+
for (const [name, entry] of Object.entries(meta)) if (typeof entry === "object" && entry !== null && entry.optional === true) optional.add(name);
|
|
242
|
+
return optional;
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* The chain element a node contributes, named from the supplied manifest when
|
|
246
|
+
* the node is a joined link target.
|
|
247
|
+
*
|
|
248
|
+
* @remarks
|
|
249
|
+
* The lockfile names a workspace row by its DIRECTORY (`packages/a`) at the
|
|
250
|
+
* placeholder version `0.0.0`, because pnpm records neither for an importer;
|
|
251
|
+
* `pnpm peers check` reports the real `probe-a@1.0.0` it read from the
|
|
252
|
+
* manifest, and the supplied manifest is the only place those two facts exist.
|
|
253
|
+
*
|
|
254
|
+
* @internal
|
|
255
|
+
*/
|
|
256
|
+
const parentLabel = (join, instance) => {
|
|
257
|
+
const manifest = joinedManifest(join, instance);
|
|
258
|
+
return PeerParent.make({
|
|
259
|
+
name: manifest?.name ?? instance.name,
|
|
260
|
+
version: manifest?.version ?? instance.version
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* A range or version that is really a protocol specifier (`catalog:`,
|
|
265
|
+
* `workspace:`, `npm:`, `file:` …). Neither a semver range nor a semver
|
|
266
|
+
* version starts with `<scheme>:`.
|
|
267
|
+
*
|
|
268
|
+
* @internal
|
|
269
|
+
*/
|
|
270
|
+
const PROTOCOL_SPECIFIER = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
271
|
+
/**
|
|
272
|
+
* Whether a declared range or a resolved version is a protocol specifier
|
|
273
|
+
* rather than something semver can compare.
|
|
274
|
+
*
|
|
275
|
+
* @remarks
|
|
276
|
+
* The one test both halves of a comparison use: a range that is one yields
|
|
277
|
+
* `"peerRangeUnresolved"`, a provider version that is one (`file:vendor/x`,
|
|
278
|
+
* which `@effected/lockfiles` passes through for a `file:` provider) yields
|
|
279
|
+
* `"peerVersionUnresolved"`.
|
|
280
|
+
*
|
|
281
|
+
* @internal
|
|
282
|
+
*/
|
|
283
|
+
const isProtocolSpecifier = (value) => PROTOCOL_SPECIFIER.test(value);
|
|
284
|
+
/** @internal */
|
|
285
|
+
const CATALOG_PREFIX = "catalog:";
|
|
286
|
+
/**
|
|
287
|
+
* The range a joined manifest's declared peer specifier stands for, or
|
|
288
|
+
* `undefined` when it is a protocol specifier that cannot be resolved.
|
|
289
|
+
*
|
|
290
|
+
* @remarks
|
|
291
|
+
* A `catalog:` specifier resolves through the caller's catalogs; an omitted
|
|
292
|
+
* set resolves nothing, since nothing was looked up. A resolution that is
|
|
293
|
+
* itself a protocol specifier is no more a range than the input was. A plain
|
|
294
|
+
* range passes through untouched, parseable or not — an unparseable
|
|
295
|
+
* non-protocol range is declined as {@link judge} declines one.
|
|
296
|
+
*
|
|
297
|
+
* @internal
|
|
298
|
+
*/
|
|
299
|
+
const resolvePeerRange = (catalogs, peer, wanted) => {
|
|
300
|
+
let range = wanted;
|
|
301
|
+
if (wanted.startsWith(CATALOG_PREFIX)) {
|
|
302
|
+
const resolved = catalogs === void 0 ? Option.none() : catalogs.resolveSpecifier(peer, wanted);
|
|
303
|
+
if (Option.isNone(resolved)) return void 0;
|
|
304
|
+
range = resolved.value;
|
|
305
|
+
}
|
|
306
|
+
return isProtocolSpecifier(range) ? void 0 : range;
|
|
307
|
+
};
|
|
308
|
+
/** @internal */
|
|
309
|
+
const SATISFIED = { _tag: "satisfied" };
|
|
310
|
+
/**
|
|
311
|
+
* A lockfile row's {@link Verdict}, carrying the declared range as `wanted`.
|
|
312
|
+
*
|
|
313
|
+
* @internal
|
|
314
|
+
*/
|
|
315
|
+
const withWanted = (verdict, wanted) => verdict._tag === "unsatisfied" ? {
|
|
316
|
+
...verdict,
|
|
317
|
+
wanted
|
|
318
|
+
} : verdict;
|
|
319
|
+
/**
|
|
320
|
+
* Compare a resolved version against a wanted range, both already known not
|
|
321
|
+
* to be protocol specifiers.
|
|
322
|
+
*
|
|
323
|
+
* @remarks
|
|
324
|
+
* Either side failing to parse is `"satisfied"` in the sense of "no row":
|
|
325
|
+
* asserting "unsatisfied" on a comparison that was never performed would be a
|
|
326
|
+
* wrong answer rather than a gap (see {@link PeerCheck.run}).
|
|
327
|
+
*
|
|
328
|
+
* @internal
|
|
329
|
+
*/
|
|
330
|
+
const compare = (wanted, found) => {
|
|
331
|
+
const range = Range.parseResult(wanted);
|
|
332
|
+
const version = SemVer.parseResult(found);
|
|
333
|
+
if (Result.isFailure(range) || Result.isFailure(version)) return SATISFIED;
|
|
334
|
+
if (Range.satisfies(version.success, range.success)) return SATISFIED;
|
|
335
|
+
return {
|
|
336
|
+
_tag: "unsatisfied",
|
|
337
|
+
found
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Decide whether one peer DECLARED BY A JOINED MANIFEST is unsatisfied.
|
|
342
|
+
*
|
|
343
|
+
* @remarks
|
|
344
|
+
* Same three judgements as {@link judge}, against a different provider set:
|
|
345
|
+
* the linked parent's own `resolved` map is empty by construction (pnpm
|
|
346
|
+
* records no peer declarations or resolutions for workspace projects), so the
|
|
347
|
+
* provider is whatever the importer's own dependency set gives that name —
|
|
348
|
+
* see {@link providerContext}.
|
|
349
|
+
*
|
|
350
|
+
* A manifest range may be a protocol specifier rather than a range —
|
|
351
|
+
* `catalog:build:peers` in a repo that sources peers from a catalog,
|
|
352
|
+
* `workspace:*` in one that pins peers to its own version. A `catalog:`
|
|
353
|
+
* specifier is resolved through the supplied catalogs (see
|
|
354
|
+
* {@link resolvePeerRange}) and reported by its resolved range, as pnpm does.
|
|
355
|
+
* One that stays unresolvable with a non-workspace provider present is
|
|
356
|
+
* `"unjudged"`: the comparison was never performed, so the report is marked
|
|
357
|
+
* `"peerRangeUnresolved"` rather than calling the peer satisfied. With NO
|
|
358
|
+
* provider the row needs no range, so it is still reported, echoing whatever
|
|
359
|
+
* range could be named.
|
|
360
|
+
*
|
|
361
|
+
* A non-workspace provider whose version is a protocol specifier (a `file:`
|
|
362
|
+
* dependency) is `"unjudged"` the same way, as `"peerVersionUnresolved"`: see
|
|
363
|
+
* {@link judge}. pnpm reads such a provider's real version off disk for a
|
|
364
|
+
* joined parent (`filedep-joined/`), which this check cannot, so it declines
|
|
365
|
+
* rather than guessing. An unresolvable range and version raise both reasons.
|
|
366
|
+
*
|
|
367
|
+
* @internal
|
|
368
|
+
*/
|
|
369
|
+
const judgeJoined = (context, catalogs, peer, declared, optional) => {
|
|
370
|
+
const resolvedRange = resolvePeerRange(catalogs, peer, declared);
|
|
371
|
+
const wanted = resolvedRange ?? declared;
|
|
372
|
+
const provider = context.get(peer);
|
|
373
|
+
if (provider === void 0) {
|
|
374
|
+
if (optional) return SATISFIED;
|
|
375
|
+
return {
|
|
376
|
+
_tag: "unsatisfied",
|
|
377
|
+
found: null,
|
|
378
|
+
wanted
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (provider.isWorkspace) return SATISFIED;
|
|
382
|
+
const reasons = [];
|
|
383
|
+
if (resolvedRange === void 0) reasons.push("peerRangeUnresolved");
|
|
384
|
+
if (isProtocolSpecifier(provider.version)) reasons.push("peerVersionUnresolved");
|
|
385
|
+
if (resolvedRange === void 0 || reasons.length > 0) return {
|
|
386
|
+
_tag: "unjudged",
|
|
387
|
+
reasons
|
|
388
|
+
};
|
|
389
|
+
return withWanted(compare(resolvedRange, provider.version), wanted);
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
82
392
|
* The result of checking a lockfile for unsatisfied peer dependencies.
|
|
83
393
|
*
|
|
84
394
|
* @remarks
|
|
@@ -138,10 +448,15 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
138
448
|
*
|
|
139
449
|
* @remarks
|
|
140
450
|
* See {@link UnverifiedReason}. A gate must treat a non-empty `unverified`
|
|
141
|
-
* as **not proven clean** rather than as a pass:
|
|
451
|
+
* as **not proven clean** rather than as a pass: every reason means some
|
|
142
452
|
* finding may be missing or spurious, and failing closed is the requirement.
|
|
143
453
|
*/
|
|
144
|
-
unverified: Schema.Array(Schema.Literals([
|
|
454
|
+
unverified: Schema.Array(Schema.Literals([
|
|
455
|
+
"peerRulesNotApplied",
|
|
456
|
+
"unresolvedEdge",
|
|
457
|
+
"peerRangeUnresolved",
|
|
458
|
+
"peerVersionUnresolved"
|
|
459
|
+
]))
|
|
145
460
|
}) {
|
|
146
461
|
/** The unsatisfied peers a gate should act on — the non-optional ones. */
|
|
147
462
|
get required() {
|
|
@@ -159,8 +474,13 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
159
474
|
* `resolved` edges, so a peer declared by a *transitive* dependency is
|
|
160
475
|
* attributed to the importer that pulls it in, with the chain in
|
|
161
476
|
* `parents` — matching how `pnpm peers check` attributes them.
|
|
477
|
+
* Attribution stops at a workspace package: a linked package's own
|
|
478
|
+
* dependencies — registry or linked — are judged by that package's own
|
|
479
|
+
* importer, and a linked package's manifest peers are judged only for the
|
|
480
|
+
* importer that links it DIRECTLY, against that importer's dependencies.
|
|
481
|
+
* pnpm never surfaces them on a consumer one link further out.
|
|
162
482
|
*
|
|
163
|
-
*
|
|
483
|
+
* Four judgements are deliberate:
|
|
164
484
|
*
|
|
165
485
|
* - **A required peer with nothing resolved is reported** (`found: null`),
|
|
166
486
|
* whether or not the declared range is parseable — a peer with no
|
|
@@ -173,6 +493,9 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
173
493
|
* - **An unparseable range or version with something resolved is skipped.**
|
|
174
494
|
* The check cannot judge it, and asserting "unsatisfied" on a comparison
|
|
175
495
|
* that was never performed would be a wrong answer rather than a gap.
|
|
496
|
+
* Two protocol-specifier cases are the exception and fail the report
|
|
497
|
+
* closed instead: a joined manifest's range, and a provider whose version
|
|
498
|
+
* is a protocol specifier (both below).
|
|
176
499
|
* - **A workspace-linked provider counts.** Resolution is followed through
|
|
177
500
|
* whatever the edge names, including a workspace row.
|
|
178
501
|
*
|
|
@@ -180,7 +503,44 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
180
503
|
* bun root importer (see `unresolvedImporters`), and pnpm recording no peer
|
|
181
504
|
* declarations for workspace projects themselves — under pnpm a workspace
|
|
182
505
|
* package's *own* unsatisfied peers are not in the lockfile at all, and
|
|
183
|
-
* `pnpm peers check` does not report them either.
|
|
506
|
+
* `pnpm peers check` does not report them either. A `link:`-resolved
|
|
507
|
+
* importer dependency is the same blind spot from the other side, and there
|
|
508
|
+
* `pnpm peers check` DOES report the linked parent's peers (it reads the
|
|
509
|
+
* manifest on disk): every `link:` edge whose target the caller did not
|
|
510
|
+
* supply therefore fails the report closed rather than letting the
|
|
511
|
+
* invisible parent pass as checked (effected#800).
|
|
512
|
+
*
|
|
513
|
+
* Supplying {@link PeerCheckOptions.workspacePackages} closes that gap for
|
|
514
|
+
* every `link:` target the set covers — the root importer's included,
|
|
515
|
+
* whose linked targets the walk seeds itself because the root has no
|
|
516
|
+
* workspace row to reach them through: the claimed manifest's declared
|
|
517
|
+
* peers are walked, judged against the importer's own dependency set
|
|
518
|
+
* (never a sibling importer's), and reported with the manifest's name and
|
|
519
|
+
* version in `parents` — the two facts a workspace row cannot carry, since
|
|
520
|
+
* the lockfile names an importer by directory at the placeholder version
|
|
521
|
+
* `0.0.0`.
|
|
522
|
+
*
|
|
523
|
+
* A joined manifest's peer range may be a protocol specifier rather than a
|
|
524
|
+
* range. A `catalog:` specifier is resolved through
|
|
525
|
+
* {@link PeerCheckOptions.catalogs} and judged and reported by its
|
|
526
|
+
* resolved range, as `pnpm peers check` reports `wantedRange`. One that
|
|
527
|
+
* stays unresolvable — the catalogs key omitted, a catalog naming nothing
|
|
528
|
+
* for the peer, or another protocol such as `workspace:*` — is not judged
|
|
529
|
+
* against a provider that resolved for it, and the report carries
|
|
530
|
+
* `"peerRangeUnresolved"` rather than passing the peer as satisfied. With
|
|
531
|
+
* nothing resolved for it, the row is still reported, since "no provider"
|
|
532
|
+
* needs no range.
|
|
533
|
+
*
|
|
534
|
+
* The provider side has the same hole: a peer that resolved to a `file:`
|
|
535
|
+
* dependency, directly or through a `file:` override, carries the specifier
|
|
536
|
+
* (`file:vendor/react`) where a version belongs, and for a `file:` directory
|
|
537
|
+
* the lockfile records no version anywhere. `pnpm peers check` reports such a
|
|
538
|
+
* peer as a `bad` row with the specifier as its found version, even when the
|
|
539
|
+
* directory's manifest satisfies the range. Such a peer is neither reported
|
|
540
|
+
* nor passed: no row is fabricated, and the report carries
|
|
541
|
+
* `"peerVersionUnresolved"`, for a lockfile row's peer and a joined
|
|
542
|
+
* manifest's peer alike. A workspace-row provider is still accepted without a
|
|
543
|
+
* version check, and a plain unparseable version is still skipped.
|
|
184
544
|
*
|
|
185
545
|
* @param lockfile - a lockfile parsed by `@effected/lockfiles`
|
|
186
546
|
* @returns the report; never fails
|
|
@@ -202,18 +562,60 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
202
562
|
});
|
|
203
563
|
const index = indexInstances(lockfile);
|
|
204
564
|
const { byId } = index;
|
|
565
|
+
const manifests = /* @__PURE__ */ new Map();
|
|
566
|
+
const byLinkTarget = /* @__PURE__ */ new Map();
|
|
567
|
+
for (const pkg of options?.workspacePackages ?? []) {
|
|
568
|
+
const relativePath = linkTargetPath(".", pkg.relativePath);
|
|
569
|
+
if (!manifests.has(relativePath)) manifests.set(relativePath, pkg);
|
|
570
|
+
for (const target of linkTargetsOf(relativePath, pkg)) if (!byLinkTarget.has(target)) byLinkTarget.set(target, pkg);
|
|
571
|
+
}
|
|
572
|
+
const joinable = /* @__PURE__ */ new Set();
|
|
573
|
+
const linksByImporter = /* @__PURE__ */ new Map();
|
|
574
|
+
let unjoinedLink = false;
|
|
575
|
+
for (const importer of lockfile.importers) {
|
|
576
|
+
const edges = [];
|
|
577
|
+
for (const dep of importer.dependencies) {
|
|
578
|
+
const version = dep.version;
|
|
579
|
+
if (version?.startsWith(LINK_PREFIX) !== true) continue;
|
|
580
|
+
const target = linkTargetPath(importer.path, version.slice(5));
|
|
581
|
+
const pkg = byLinkTarget.get(target);
|
|
582
|
+
const row = index.workspaceByPath.get(pkg === void 0 ? target : linkTargetPath(".", pkg.relativePath));
|
|
583
|
+
const covered = pkg !== void 0;
|
|
584
|
+
if (!covered) unjoinedLink = true;
|
|
585
|
+
if (covered && row !== void 0) joinable.add(row.instanceId);
|
|
586
|
+
edges.push({
|
|
587
|
+
name: dep.name,
|
|
588
|
+
row,
|
|
589
|
+
covered
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (edges.length > 0) linksByImporter.set(importer.path, edges);
|
|
593
|
+
}
|
|
205
594
|
const rows = [];
|
|
206
595
|
const unresolved = [];
|
|
596
|
+
const unjudged = /* @__PURE__ */ new Set();
|
|
207
597
|
const seen = /* @__PURE__ */ new Set();
|
|
208
598
|
for (const importer of lockfile.importers) {
|
|
209
|
-
const
|
|
599
|
+
const links = linksByImporter.get(importer.path) ?? [];
|
|
600
|
+
const roots = withLinkRoots(rootInstances(lockfile, importer.path, index), links, importer.dependencies);
|
|
210
601
|
if (roots === void 0) {
|
|
211
602
|
unresolved.push(importer.path);
|
|
603
|
+
if (links.length > 0) unjoinedLink = true;
|
|
212
604
|
continue;
|
|
213
605
|
}
|
|
214
|
-
|
|
606
|
+
const join = {
|
|
607
|
+
manifests,
|
|
608
|
+
joinable,
|
|
609
|
+
context: providerContext(roots, byId, links),
|
|
610
|
+
catalogs: options?.catalogs
|
|
611
|
+
};
|
|
612
|
+
const judged = /* @__PURE__ */ new Set();
|
|
613
|
+
collect(importer.path, walksFrom(roots, join), byId, rows, seen, policy, join, judged, unjudged);
|
|
614
|
+
for (const edge of links) if (edge.covered && (edge.row === void 0 || !judged.has(edge.row.instanceId))) unjoinedLink = true;
|
|
215
615
|
}
|
|
216
|
-
if (lockfile.packages.some((pkg) => pkg.unresolvedEdges.length > 0)) unverified.push("unresolvedEdge");
|
|
616
|
+
if (unjoinedLink || lockfile.packages.some((pkg) => pkg.unresolvedEdges.length > 0)) unverified.push("unresolvedEdge");
|
|
617
|
+
if (unjudged.has("peerRangeUnresolved")) unverified.push("peerRangeUnresolved");
|
|
618
|
+
if (unjudged.has("peerVersionUnresolved")) unverified.push("peerVersionUnresolved");
|
|
217
619
|
return PeerCheck.make({
|
|
218
620
|
supported: true,
|
|
219
621
|
unsatisfied: rows,
|
|
@@ -231,23 +633,24 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
|
231
633
|
*
|
|
232
634
|
* @internal
|
|
233
635
|
*/
|
|
234
|
-
const walksFrom = (roots) => roots._tag === "own" ? [{
|
|
636
|
+
const walksFrom = (roots, join) => roots._tag === "own" ? [{
|
|
235
637
|
instance: roots.instance,
|
|
236
638
|
path: []
|
|
237
639
|
}] : roots.instances.map((instance) => ({
|
|
238
640
|
instance,
|
|
239
|
-
path: [
|
|
240
|
-
name: instance.name,
|
|
241
|
-
version: instance.version
|
|
242
|
-
})]
|
|
641
|
+
path: [parentLabel(join, instance)]
|
|
243
642
|
}));
|
|
244
643
|
/**
|
|
245
644
|
* Walk the resolution graph from an importer's roots, emitting a row for every
|
|
246
645
|
* unsatisfied peer declared anywhere along the way.
|
|
247
646
|
*
|
|
647
|
+
* Adds to `unjudged` the reason each peer it could not judge was never
|
|
648
|
+
* compared (see {@link judge} and {@link judgeJoined}); the caller surfaces
|
|
649
|
+
* them on `unverified`.
|
|
650
|
+
*
|
|
248
651
|
* @internal
|
|
249
652
|
*/
|
|
250
|
-
const collect = (importerPath, roots, byId, rows, seen, policy) => {
|
|
653
|
+
const collect = (importerPath, roots, byId, rows, seen, policy, join, judged, unjudged) => {
|
|
251
654
|
const visited = /* @__PURE__ */ new Set();
|
|
252
655
|
const queue = [...roots];
|
|
253
656
|
while (queue.length > 0) {
|
|
@@ -255,33 +658,37 @@ const collect = (importerPath, roots, byId, rows, seen, policy) => {
|
|
|
255
658
|
if (current === void 0) break;
|
|
256
659
|
if (visited.has(current.instance.instanceId)) continue;
|
|
257
660
|
visited.add(current.instance.instanceId);
|
|
258
|
-
|
|
661
|
+
const linked = current.path.length === 0 ? void 0 : joinedManifest(join, current.instance);
|
|
662
|
+
const declared = linked?.peerDependencies ?? current.instance.peerDependencies;
|
|
663
|
+
const optionalNames = linked === void 0 ? void 0 : optionalPeers(linked);
|
|
664
|
+
const declaring = linked?.name ?? current.instance.name;
|
|
665
|
+
if (linked !== void 0) judged.add(current.instance.instanceId);
|
|
666
|
+
for (const [peer, wanted] of Object.entries(declared)) {
|
|
259
667
|
if (peer === "") continue;
|
|
260
|
-
const optional = current.instance.peerDependenciesMeta[peer]?.optional === true;
|
|
261
|
-
const verdict = judge(current.instance, peer, wanted, optional, byId);
|
|
262
|
-
if (verdict ===
|
|
263
|
-
if (
|
|
668
|
+
const optional = optionalNames === void 0 ? current.instance.peerDependenciesMeta[peer]?.optional === true : optionalNames.has(peer);
|
|
669
|
+
const verdict = linked === void 0 ? withWanted(judge(current.instance, peer, wanted, optional, byId), wanted) : judgeJoined(join.context, join.catalogs, peer, wanted, optional);
|
|
670
|
+
if (verdict._tag === "unjudged") for (const reason of verdict.reasons) unjudged.add(reason);
|
|
671
|
+
if (verdict._tag !== "unsatisfied") continue;
|
|
672
|
+
if (suppressedByRule(policy, declaring, peer, verdict.found)) continue;
|
|
264
673
|
const key = `${importerPath}\u0000${peer}\u0000${current.instance.instanceId}`;
|
|
265
674
|
if (seen.has(key)) continue;
|
|
266
675
|
seen.add(key);
|
|
267
676
|
rows.push(UnsatisfiedPeer.make({
|
|
268
677
|
importer: importerPath,
|
|
269
678
|
dependency: peer,
|
|
270
|
-
wanted,
|
|
679
|
+
wanted: verdict.wanted,
|
|
271
680
|
found: verdict.found,
|
|
272
681
|
optional,
|
|
273
682
|
parents: current.path
|
|
274
683
|
}));
|
|
275
684
|
}
|
|
685
|
+
if (current.path.length > 0 && current.instance.isWorkspace) continue;
|
|
276
686
|
for (const targetId of Object.values(current.instance.resolved)) {
|
|
277
687
|
const next = byId.get(targetId);
|
|
278
688
|
if (next === void 0 || visited.has(targetId)) continue;
|
|
279
689
|
queue.push({
|
|
280
690
|
instance: next,
|
|
281
|
-
path: [...current.path,
|
|
282
|
-
name: next.name,
|
|
283
|
-
version: next.version
|
|
284
|
-
})]
|
|
691
|
+
path: [...current.path, parentLabel(join, next)]
|
|
285
692
|
});
|
|
286
693
|
}
|
|
287
694
|
}
|
|
@@ -360,9 +767,12 @@ const suppressedByRule = (policy, parentName, peer, found) => {
|
|
|
360
767
|
/**
|
|
361
768
|
* Decide whether one declared peer is unsatisfied.
|
|
362
769
|
*
|
|
363
|
-
* Returns `
|
|
364
|
-
* produce no row. They are deliberately merged
|
|
365
|
-
* TSDoc on {@link PeerCheck.run}
|
|
770
|
+
* Returns `"satisfied"` for "satisfied, or not judgeable and skipped" — the
|
|
771
|
+
* two cases that produce no row and no marker. They are deliberately merged
|
|
772
|
+
* here and separated in the TSDoc on {@link PeerCheck.run}. A provider whose
|
|
773
|
+
* version is a protocol specifier is the one unjudgeable case that is NOT
|
|
774
|
+
* skipped: it is `"unjudged"`, and the report carries
|
|
775
|
+
* `"peerVersionUnresolved"`.
|
|
366
776
|
*
|
|
367
777
|
* @internal
|
|
368
778
|
*/
|
|
@@ -370,16 +780,19 @@ const judge = (instance, peer, wanted, optional, byId) => {
|
|
|
370
780
|
const providerId = instance.resolved[peer];
|
|
371
781
|
const provider = providerId === void 0 ? void 0 : byId.get(providerId);
|
|
372
782
|
if (provider === void 0) {
|
|
373
|
-
if (optional) return
|
|
374
|
-
if (instance.unresolvedEdges.includes(peer)) return
|
|
375
|
-
return {
|
|
783
|
+
if (optional) return SATISFIED;
|
|
784
|
+
if (instance.unresolvedEdges.includes(peer)) return SATISFIED;
|
|
785
|
+
return {
|
|
786
|
+
_tag: "unsatisfied",
|
|
787
|
+
found: null
|
|
788
|
+
};
|
|
376
789
|
}
|
|
377
|
-
if (provider.isWorkspace) return
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return
|
|
790
|
+
if (provider.isWorkspace) return SATISFIED;
|
|
791
|
+
if (isProtocolSpecifier(provider.version)) return {
|
|
792
|
+
_tag: "unjudged",
|
|
793
|
+
reasons: ["peerVersionUnresolved"]
|
|
794
|
+
};
|
|
795
|
+
return compare(wanted, provider.version);
|
|
383
796
|
};
|
|
384
797
|
|
|
385
798
|
//#endregion
|
package/README.md
CHANGED
|
@@ -163,16 +163,21 @@ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyErro
|
|
|
163
163
|
`PeerCheck.run(lockfile, options?)` reports unsatisfied peer dependencies as a pure value: no IO, no error channel, nothing in `R`, and no per-manager traversal logic. It reads `lockfile.format` once, to reject a format whose lockfile does not record peer resolution; past that gate the walk is the same for every manager. The answer comes from the resolved graph `@effected/lockfiles` normalizes, not from shelling out to a package manager's own peer command — bun has none, so the subprocess route cannot answer for every manager the rest of this package supports.
|
|
164
164
|
|
|
165
165
|
```ts
|
|
166
|
-
import { LockfileReader, PeerCheck, WorkspaceCatalogs } from "@effected/workspaces";
|
|
166
|
+
import { LockfileReader, PeerCheck, WorkspaceCatalogs, WorkspaceDiscovery } from "@effected/workspaces";
|
|
167
167
|
import { Effect } from "effect";
|
|
168
168
|
|
|
169
169
|
const program = Effect.gen(function* () {
|
|
170
170
|
const reader = yield* LockfileReader;
|
|
171
171
|
const catalogs = yield* WorkspaceCatalogs;
|
|
172
|
+
const discovery = yield* WorkspaceDiscovery;
|
|
172
173
|
|
|
173
174
|
const lockfile = yield* reader.read();
|
|
174
|
-
// Presence of
|
|
175
|
-
const report = PeerCheck.run(lockfile, {
|
|
175
|
+
// Presence of each key is the assertion — see below.
|
|
176
|
+
const report = PeerCheck.run(lockfile, {
|
|
177
|
+
peerDependencyRules: yield* catalogs.peerDependencyRules(),
|
|
178
|
+
workspacePackages: yield* discovery.listPackages(),
|
|
179
|
+
catalogs: yield* catalogs.set(),
|
|
180
|
+
});
|
|
176
181
|
|
|
177
182
|
return {
|
|
178
183
|
clean:
|
|
@@ -190,10 +195,20 @@ const program = Effect.gen(function* () {
|
|
|
190
195
|
|
|
191
196
|
- `supported` is `false` for yarn, which resolves peers virtually and does not record which virtual instance satisfied which peer. The answer is unrecoverable, so it is not fabricated.
|
|
192
197
|
- `unresolvedImporters` names importers that could not be joined to package instances — in practice the root importer under npm and bun, neither of which records a resolved version per importer dependency.
|
|
193
|
-
- `unverified` says why the report is not a complete answer
|
|
198
|
+
- `unverified` says why the report is not a complete answer, and every reason means fail closed:
|
|
199
|
+
- `"peerRulesNotApplied"` — the `peerDependencyRules` key was omitted, so pnpm's suppression policy was not applied.
|
|
200
|
+
- `"unresolvedEdge"` — an instance records an edge the model could not name, or an importer dependency resolved through `link:` whose linked manifest's peers were not judged: its target was not supplied in `workspacePackages`, or the lockfile records no workspace row to walk. A linked parent's manifest peers are never in the lockfile, though `pnpm peers check` reads them from disk.
|
|
201
|
+
- `"peerRangeUnresolved"` — a linked manifest declares a peer as a protocol specifier that could not be turned into a range (`catalog:` with the `catalogs` key omitted or no matching entry, `workspace:*`), and something resolved for that peer, so the comparison never ran.
|
|
202
|
+
- `"peerVersionUnresolved"` — a peer resolved to a non-workspace provider whose version is a protocol specifier rather than a version: a `file:` directory or tarball, directly or through a `file:` override, which the lockfile records as `file:vendor/x` with no version for a directory, and a git or remote-tarball provider, which pnpm keys by its URL (`https://codeload.github.com/…`). The comparison never runs, so the peer is neither reported nor passed as satisfied; for a `file:` directory `pnpm peers check` reports it as a `bad` row even when the directory's manifest satisfies the range. It applies to peers declared by lockfile rows and by joined manifests alike.
|
|
194
203
|
- `required` is the getter for the rows a gate should act on — the non-optional ones. An unsatisfied *optional* peer is normal, so `optional` travels with the row rather than being filtered out at the source.
|
|
195
204
|
|
|
196
|
-
`WorkspaceCatalogs.peerDependencyRules()` returns the workspace's effective merged pnpm suppression rules, which the lockfile records nowhere; without them a checker reports findings pnpm itself calls clean. **Presence of the `peerDependencyRules` option key is the assertion, not its contents.** Passing `NoPeerDependencyRules` asserts the workspace has none, so the report carries no `"peerRulesNotApplied"` — though it can still be unverified for another reason, or unsupported; omitting the key says nobody looked, and always yields `"peerRulesNotApplied"`.
|
|
205
|
+
`WorkspaceCatalogs.peerDependencyRules()` returns the workspace's effective merged pnpm suppression rules, which the lockfile records nowhere; without them a checker reports findings pnpm itself calls clean. **Presence of the `peerDependencyRules` option key is the assertion, not its contents.** Passing `NoPeerDependencyRules` asserts the workspace has none, so the report carries no `"peerRulesNotApplied"` — though it can still be unverified for another reason, or unsupported; omitting the key says nobody looked, and always yields `"peerRulesNotApplied"`. **All three axes of the supplied rules are applied**, with pnpm's measured semantics. `allowedVersions` hides a row whose resolved version a matching key's range permits, in all three key spellings pnpm accepts: `parent@version>peer` (what `pnpm:export` writes; the parent version is ignored, as pnpm ignores it), `parent>peer` (what a config-dependency plugin injects) and a bare `peer`, which pnpm applies to every parent that declares it. `ignoreMissing` hides a required peer nothing resolved for, and `allowAny` hides a peer that resolved outside its range; both are `@pnpm/matcher` patterns over the PEER name (`*` wildcard, leading `!` negates), never `parent>peer` keys, and the two never cross.
|
|
206
|
+
|
|
207
|
+
The `workspacePackages` option takes the packages `WorkspaceDiscovery` already returned, and supplying it is what turns a `link:`-resolved parent from a refusal into an answer: the matched manifest's declared peers join the walk, named from the manifest (`probe-a@1.0.0`, not the row's `packages/a@0.0.0`) and judged against the **importer's own** dependency set, which is where pnpm resolves them from. Measured one variable at a time on the probe workspace, against pnpm 12.5.1 and 12.6.0: a consumer's own `react@18.3.1` satisfies the linked parent's `^18.0.0` peer, the same version installed only by a sibling importer does not, and a consumer's own `react@17.0.2` is a `bad` row carrying `foundVersion`. A target the set does not cover — every target, when the key is omitted — keeps `"unresolvedEdge"`, so the option answers only for what it covers and says so about the rest. A covered target the walk cannot reach — one the lockfile records no workspace row for — keeps the marker too; the root importer's linked targets are walked and judged against the root's own dependencies, as pnpm does.
|
|
208
|
+
|
|
209
|
+
A `link:` target matches a supplied package by its directory or by its publish directory: pnpm links a workspace dependency into `publishConfig.directory` unless `publishConfig.linkDirectory` is `false` (it defaults to true), recording `link:../a/dist`. For such a link pnpm reads the built manifest at the target, while `PeerCheck` reads the source manifest and resolves its `catalog:` ranges through `catalogs`; the two agree when the build emits the ranges the source's specifiers resolve to. Attribution stops at a workspace package: a linked package's manifest peers are judged only for the importer that links it directly, and its own dependencies (registry or linked) only for its own importer, never for a consumer one link further out. Each `link:` edge is cleared only by the walk of the importer that records it.
|
|
210
|
+
|
|
211
|
+
The `catalogs` option takes `WorkspaceCatalogs.set()`, which already includes hook-injected catalogs. A linked manifest may declare a peer as a catalog specifier (`"typescript": "catalog:build:peers"`) rather than a range; with the set supplied it is resolved, judged, and reported by the resolved range, which is what `pnpm peers check` reports as `wantedRange`. A specifier that stays unresolvable — the key omitted, a catalog naming nothing for the peer, or another protocol such as `workspace:*` — is never passed as satisfied: when something resolved for that peer the report carries `"peerRangeUnresolved"`, and when nothing did the `found: null` row is reported anyway, since a missing provider needs no range.
|
|
197
212
|
|
|
198
213
|
## The synchronous escape hatch
|
|
199
214
|
|