@effected/workspaces 0.28.0 → 0.30.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.
@@ -5,7 +5,7 @@ import { Context, Duration, Effect, Layer, Predicate, Schema } from "effect";
5
5
  import { CatalogAssemblyError } from "@effected/npm";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { Run } from "@effected/commands";
8
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
9
9
 
10
10
  //#region src/ConfigDependencyHooks.ts
11
11
  /**
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 lockfile = yield* Lockfile.parse(content, { format });
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/PackedInstall.js CHANGED
@@ -4,7 +4,7 @@ import { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, in
4
4
  import { Duration, Effect, FileSystem, Option, Path, Redacted, Result, Schema } from "effect";
5
5
  import { Yaml } from "@effected/yaml";
6
6
  import { Run } from "@effected/commands";
7
- import { ChildProcess } from "effect/unstable/process";
7
+ import { ChildProcess } from "effect/process";
8
8
 
9
9
  //#region src/PackedInstall.ts
10
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: both reasons mean some
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(["peerRulesNotApplied", "unresolvedEdge"]))
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
- * Three judgements are deliberate:
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 roots = rootInstances(lockfile, importer.path, index);
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
- collect(importer.path, walksFrom(roots), byId, rows, seen, policy);
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: [PeerParent.make({
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
- for (const [peer, wanted] of Object.entries(current.instance.peerDependencies)) {
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 === void 0) continue;
263
- if (suppressedByRule(policy, current.instance.name, peer, verdict.found)) continue;
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, PeerParent.make({
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 `undefined` for "satisfied, or not judgeable" — the two cases that
364
- * produce no row. They are deliberately merged here and separated in the
365
- * TSDoc on {@link PeerCheck.run}, since neither yields output.
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 void 0;
374
- if (instance.unresolvedEdges.includes(peer)) return void 0;
375
- return { found: null };
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 void 0;
378
- const range = Range.parseResult(wanted);
379
- const version = SemVer.parseResult(provider.version);
380
- if (Result.isFailure(range) || Result.isFailure(version)) return void 0;
381
- if (Range.satisfies(version.success, range.success)) return void 0;
382
- return { found: provider.version };
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