@effected/workspaces 0.14.2 → 0.15.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.
@@ -7,6 +7,27 @@ import { Run } from "@effected/commands";
7
7
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
8
 
9
9
  //#region src/ConfigDependencyHooks.ts
10
+ /**
11
+ * The empty {@link PeerDependencyRules}: every axis present and empty.
12
+ *
13
+ * @remarks
14
+ * Exported so that "I assert this workspace's rules are empty" is **one token**
15
+ * rather than three hand-written empty axes. That matters where the distinction
16
+ * is load-bearing — supplying rules asserts they were looked up, while omitting
17
+ * them asserts nothing — and a caller spelling the object out by hand will
18
+ * eventually fill two of the three axes and mean the third.
19
+ *
20
+ * Frozen, since it is shared.
21
+ *
22
+ * @public
23
+ */
24
+ const NoPeerDependencyRules = Object.freeze({
25
+ allowedVersions: Object.freeze({}),
26
+ ignoreMissing: Object.freeze([]),
27
+ allowAny: Object.freeze([])
28
+ });
29
+ /** Internal alias, kept short at the many call sites in this module. */
30
+ const NO_PEER_RULES = NoPeerDependencyRules;
10
31
  /** Whether `value` is a non-null, non-array object. */
11
32
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
33
  /**
@@ -34,7 +55,7 @@ const moduleNotFoundUrl = (cause) => isObject(cause) && cause.code === "ERR_MODU
34
55
  */
35
56
  const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
36
57
  /** Turn the seed record into the pnpm hook config, with the default catalog split out under `catalog`. */
37
- const seedToConfig = (seed) => {
58
+ const seedToConfig = (seed, rules) => {
38
59
  const catalogs = {};
39
60
  let catalog = {};
40
61
  for (const [name, entries] of Object.entries(seed)) if (name === "default") catalog = { ...entries };
@@ -43,7 +64,8 @@ const seedToConfig = (seed) => {
43
64
  catalog,
44
65
  catalogs,
45
66
  minimumReleaseAge: void 0,
46
- minimumReleaseAgeExclude: void 0
67
+ minimumReleaseAgeExclude: void 0,
68
+ peerDependencyRules: rules
47
69
  };
48
70
  };
49
71
  /** A finite number if `value` is one, else the prior threaded value — a garbage age is dropped, not fatal. */
@@ -68,9 +90,32 @@ const configOf = (value, fallback) => {
68
90
  catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
69
91
  catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
70
92
  minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
71
- minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude)
93
+ minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
94
+ peerDependencyRules: peerRulesOr(value.peerDependencyRules, fallback.peerDependencyRules)
95
+ };
96
+ };
97
+ /**
98
+ * A peer-rules block if `value` is one, else the prior threaded value — a
99
+ * malformed block is dropped, not fatal, matching the other slices.
100
+ *
101
+ * Each axis is normalized independently: a hook that rewrites `allowedVersions`
102
+ * and leaves `ignoreMissing` alone must not blank the latter.
103
+ */
104
+ const stringRecordOr = (value, fallback) => isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
105
+ const peerRulesOr = (value, fallback) => {
106
+ if (!isObject(value)) return fallback;
107
+ const allowed = stringRecordOr(value.allowedVersions, fallback?.allowedVersions);
108
+ const ignoreMissing = stringArrayOr(value.ignoreMissing, fallback?.ignoreMissing);
109
+ const allowAny = stringArrayOr(value.allowAny, fallback?.allowAny);
110
+ if (allowed === void 0 && ignoreMissing === void 0 && allowAny === void 0) return fallback;
111
+ return {
112
+ allowedVersions: allowed ?? {},
113
+ ignoreMissing: ignoreMissing ?? [],
114
+ allowAny: allowAny ?? []
72
115
  };
73
116
  };
117
+ /** Project the threaded config's rules into the slice, defaulting every axis to empty. */
118
+ const peerRulesOf = (config) => config.peerDependencyRules ?? NO_PEER_RULES;
74
119
  /**
75
120
  * Project the threaded config's release-age keys into a partial gate
76
121
  * contribution, omitting a key the hooks never set (never an explicit
@@ -124,13 +169,23 @@ const updateConfigOf = (mod) => {
124
169
  * noise on stdout.
125
170
  */
126
171
  const REPLAY_SCRIPT = `
127
- const [root, seedJson, ...names] = process.argv.slice(1);
172
+ const [root, seedJson, rulesJson, ...names] = process.argv.slice(1);
128
173
  const { pathToFileURL } = await import("node:url");
129
174
  const { join } = await import("node:path");
130
175
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
131
176
  const finiteNumberOr = (value, fallback) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
132
177
  const stringArrayOr = (value, fallback) =>
133
178
  Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
179
+ const stringRecordOr = (value, fallback) =>
180
+ isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
181
+ const peerRulesOr = (value, fallback) => {
182
+ if (!isObject(value)) return fallback;
183
+ const allowed = stringRecordOr(value.allowedVersions, fallback && fallback.allowedVersions);
184
+ const ignoreMissing = stringArrayOr(value.ignoreMissing, fallback && fallback.ignoreMissing);
185
+ const allowAny = stringArrayOr(value.allowAny, fallback && fallback.allowAny);
186
+ if (allowed === undefined && ignoreMissing === undefined && allowAny === undefined) return fallback;
187
+ return { allowedVersions: allowed || {}, ignoreMissing: ignoreMissing || [], allowAny: allowAny || [] };
188
+ };
134
189
  const configOf = (value, fallback) =>
135
190
  isObject(value)
136
191
  ? {
@@ -138,6 +193,7 @@ const configOf = (value, fallback) =>
138
193
  catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
139
194
  minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
140
195
  minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
196
+ peerDependencyRules: peerRulesOr(value.peerDependencyRules, fallback.peerDependencyRules),
141
197
  }
142
198
  : fallback;
143
199
  const failure = (name, cause) => ({
@@ -153,7 +209,13 @@ const replay = async () => {
153
209
  if (name === "default") catalog = { ...entries };
154
210
  else catalogs[name] = { ...entries };
155
211
  }
156
- let config = { catalog, catalogs, minimumReleaseAge: undefined, minimumReleaseAgeExclude: undefined };
212
+ let config = {
213
+ catalog,
214
+ catalogs,
215
+ minimumReleaseAge: undefined,
216
+ minimumReleaseAgeExclude: undefined,
217
+ peerDependencyRules: JSON.parse(rulesJson),
218
+ };
157
219
  for (const name of names) {
158
220
  let loaded;
159
221
  let found = false;
@@ -254,9 +316,10 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
254
316
  * config dependency. The default {@link WorkspaceCatalogs} layer wires this, so
255
317
  * the default catalog path provably executes no config-dependency code.
256
318
  */
257
- static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed) => Effect.succeed({
319
+ static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed, rules) => Effect.succeed({
258
320
  catalogs: seed,
259
- releaseAge: {}
321
+ releaseAge: {},
322
+ peerDependencyRules: rules ?? NO_PEER_RULES
260
323
  }) });
261
324
  /**
262
325
  * The live layer: dynamically imports each config dependency's `pnpmfile.cjs`
@@ -274,13 +337,14 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
274
337
  * import, so this layer runs on either runtime. Only ever wired by
275
338
  * `WorkspaceCatalogs.layerWithConfigDependencies`.
276
339
  */
277
- static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed) => Effect.gen(function* () {
340
+ static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
278
341
  const names = Object.keys(configDependencies);
279
342
  if (names.length === 0) return {
280
343
  catalogs: seed,
281
- releaseAge: {}
344
+ releaseAge: {},
345
+ peerDependencyRules: rules ?? NO_PEER_RULES
282
346
  };
283
- let config = seedToConfig(seed);
347
+ let config = seedToConfig(seed, rules);
284
348
  for (const name of names) {
285
349
  if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
286
350
  source: "hooks",
@@ -326,7 +390,8 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
326
390
  }
327
391
  return {
328
392
  catalogs: configToEntries(config),
329
- releaseAge: releaseAgeOf(config)
393
+ releaseAge: releaseAgeOf(config),
394
+ peerDependencyRules: peerRulesOf(config)
330
395
  };
331
396
  }) });
332
397
  /**
@@ -382,11 +447,12 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
382
447
  */
383
448
  static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
384
449
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
385
- return { inject: (root, configDependencies, seed) => Effect.gen(function* () {
450
+ return { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
386
451
  const names = Object.keys(configDependencies);
387
452
  if (names.length === 0) return {
388
453
  catalogs: seed,
389
- releaseAge: {}
454
+ releaseAge: {},
455
+ peerDependencyRules: rules ?? NO_PEER_RULES
390
456
  };
391
457
  for (const name of names) if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
392
458
  source: "hooks",
@@ -399,6 +465,7 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
399
465
  REPLAY_SCRIPT,
400
466
  root,
401
467
  JSON.stringify(seed),
468
+ JSON.stringify(rules ?? NO_PEER_RULES),
402
469
  ...names
403
470
  ]);
404
471
  const payload = yield* Run.jsonLine(command, ReplayPayload, { timeout: REPLAY_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.catch((cause) => Effect.fail(new CatalogAssemblyError({
@@ -411,14 +478,15 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
411
478
  path: payload.name ?? root,
412
479
  cause: replayFailureCause(payload)
413
480
  }));
414
- const config = configOf(payload.config, seedToConfig(seed));
481
+ const config = configOf(payload.config, seedToConfig(seed, rules));
415
482
  return {
416
483
  catalogs: configToEntries(config),
417
- releaseAge: releaseAgeOf(config)
484
+ releaseAge: releaseAgeOf(config),
485
+ peerDependencyRules: peerRulesOf(config)
418
486
  };
419
487
  }) };
420
488
  }));
421
489
  };
422
490
 
423
491
  //#endregion
424
- export { ConfigDependencyHooks };
492
+ export { ConfigDependencyHooks, NoPeerDependencyRules };
package/PeerCheck.js ADDED
@@ -0,0 +1,391 @@
1
+ import { Result, Schema } from "effect";
2
+ import { Range, SemVer } from "@effected/semver";
3
+
4
+ //#region src/PeerCheck.ts
5
+ /**
6
+ * One link in the chain from an importer to the package that declared an
7
+ * unsatisfied peer.
8
+ *
9
+ * @remarks
10
+ * Mirrors the shape `pnpm peers check --json` reports, so the two can be
11
+ * compared directly.
12
+ *
13
+ * @public
14
+ */
15
+ var PeerParent = class extends Schema.Class("PeerParent")({
16
+ /** The package name. */
17
+ name: Schema.NonEmptyString,
18
+ /** The resolved version of that package. */
19
+ version: Schema.String
20
+ }) {};
21
+ /**
22
+ * One peer dependency that is declared but not satisfied.
23
+ *
24
+ * @remarks
25
+ * `found` carries the version that actually resolved, or `null` when nothing
26
+ * resolved at all. There is deliberately **no separate discriminant** between
27
+ * "missing" and "unmet": `found === null` is the whole distinction, and a
28
+ * renderer reads it directly.
29
+ *
30
+ * `optional` must be respected by any gate — an unsatisfied *optional* peer is
31
+ * normal and should not fail a build, which is why the flag travels with the
32
+ * row rather than being filtered out here. {@link PeerCheck.required} is the
33
+ * convenience for the common case.
34
+ *
35
+ * `parents` is the path from the importer down to the package that declared
36
+ * the peer, nearest-to-the-importer first. It is a path rather than a single
37
+ * package because a peer declared by a transitive dependency is still that
38
+ * importer's problem, and the chain is what makes the report actionable.
39
+ *
40
+ * A package an importer reaches by more than one path yields **one** row, not
41
+ * one per path, carrying the path the walk reached first — which is what
42
+ * `pnpm peers check` reports for the same graph. Read `parents` as *a* route to
43
+ * the declaring package, never as the complete set of them.
44
+ *
45
+ * @public
46
+ */
47
+ var UnsatisfiedPeer = class extends Schema.Class("UnsatisfiedPeer")({
48
+ /** The importer path the problem belongs to (`"."` for the root). */
49
+ importer: Schema.NonEmptyString,
50
+ /** The peer dependency's name. */
51
+ dependency: Schema.NonEmptyString,
52
+ /** The range the declaring package asked for. */
53
+ wanted: Schema.String,
54
+ /** The version that resolved, or `null` when nothing resolved. */
55
+ found: Schema.NullOr(Schema.String),
56
+ /** Whether the declaring package marked the peer optional. */
57
+ optional: Schema.Boolean,
58
+ /** The path from the importer to the declaring package. */
59
+ parents: Schema.Array(PeerParent)
60
+ }) {};
61
+ /**
62
+ * The formats whose lockfiles record peer resolution.
63
+ *
64
+ * An allowlist rather than a `!== "yarn"` denylist, so a format added to
65
+ * `Lockfile["format"]` is unsupported until someone says otherwise. A denylist
66
+ * would report the new format `supported: true` with an empty `unsatisfied`
67
+ * before any parser records peer resolution for it — a clean bill of health
68
+ * produced by a limitation, which is the failure this module exists to avoid.
69
+ *
70
+ * @internal
71
+ */
72
+ const PEER_RESOLVING_FORMATS = /* @__PURE__ */ new Set([
73
+ "npm",
74
+ "pnpm",
75
+ "bun"
76
+ ]);
77
+ /** @internal */
78
+ const supportsPeerResolution = (format) => PEER_RESOLVING_FORMATS.has(format);
79
+ /**
80
+ * The result of checking a lockfile for unsatisfied peer dependencies.
81
+ *
82
+ * @remarks
83
+ * A report rather than a bare array, because an empty array is the most
84
+ * dangerous success shape this domain has: it is indistinguishable from "this
85
+ * format cannot be checked". `supported` and `unresolvedImporters` make the
86
+ * difference legible, so a consumer cannot read a limitation as a clean bill
87
+ * of health.
88
+ *
89
+ * Pure and total — construct it with {@link PeerCheck.run}, which never fails.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * import { PeerCheck } from "@effected/workspaces";
94
+ * import { Lockfile } from "@effected/lockfiles";
95
+ * import { Effect } from "effect";
96
+ *
97
+ * const program = Effect.gen(function* () {
98
+ * const lockfile = yield* Lockfile.parse(text, { format: "pnpm" });
99
+ * const report = PeerCheck.run(lockfile);
100
+ * return report.supported ? report.required : [];
101
+ * });
102
+ * ```
103
+ *
104
+ * @public
105
+ */
106
+ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
107
+ /**
108
+ * Whether the lockfile's format records peer resolution at all.
109
+ *
110
+ * `false` for yarn: yarn resolves peers **virtually**, giving a
111
+ * peer-bearing package one `@virtual:` locator per consumer, and the
112
+ * lockfile does not record which virtual instance satisfied which peer.
113
+ * The answer is not recoverable, so it is not fabricated — `unsatisfied`
114
+ * is empty and this flag says why.
115
+ */
116
+ supported: Schema.Boolean,
117
+ /** Every unsatisfied peer found, optional ones included. */
118
+ unsatisfied: Schema.Array(UnsatisfiedPeer),
119
+ /**
120
+ * Importers whose dependencies could not be resolved to instances, so no
121
+ * verdict was reached for them.
122
+ *
123
+ * @remarks
124
+ * In practice this is the **root importer under npm and bun**: neither
125
+ * records a resolved version per importer dependency, and neither emits a
126
+ * package row for the root, so there is nothing to join on. pnpm records a
127
+ * version per importer dependency and is unaffected.
128
+ *
129
+ * Reported rather than silently skipped, for the same reason `supported`
130
+ * exists: a gate that sees no rows is entitled to know whether that means
131
+ * "clean" or "not looked at".
132
+ */
133
+ unresolvedImporters: Schema.Array(Schema.String),
134
+ /**
135
+ * Why this report is not a complete answer, or empty when it is.
136
+ *
137
+ * @remarks
138
+ * See {@link UnverifiedReason}. A gate must treat a non-empty `unverified`
139
+ * as **not proven clean** rather than as a pass: both reasons mean some
140
+ * finding may be missing or spurious, and failing closed is the requirement.
141
+ */
142
+ unverified: Schema.Array(Schema.Literals(["peerRulesNotApplied", "unresolvedEdge"]))
143
+ }) {
144
+ /** The unsatisfied peers a gate should act on — the non-optional ones. */
145
+ get required() {
146
+ return this.unsatisfied.filter((row) => !row.optional);
147
+ }
148
+ /**
149
+ * Compute the unsatisfied peer dependencies of a parsed lockfile.
150
+ *
151
+ * @remarks
152
+ * Pure, total and format-free: no IO, no error channel, and no knowledge of
153
+ * which package manager wrote the file. Range satisfaction is
154
+ * `@effected/semver`'s, never hand-rolled.
155
+ *
156
+ * The walk starts at each importer's resolved dependencies and follows
157
+ * `resolved` edges, so a peer declared by a *transitive* dependency is
158
+ * attributed to the importer that pulls it in, with the chain in
159
+ * `parents` — matching how `pnpm peers check` attributes them.
160
+ *
161
+ * Three judgements are deliberate:
162
+ *
163
+ * - **A required peer with nothing resolved is reported** (`found: null`),
164
+ * whether or not the declared range is parseable — a peer with no
165
+ * provider is a fact about the graph that needs no range arithmetic.
166
+ * - **An ABSENT optional peer is not reported at all**, because that is
167
+ * what optional means. An optional peer resolved at the *wrong* version
168
+ * still is, carrying `optional: true`. Both halves match
169
+ * `pnpm peers check`, whose `missing` section contains only required
170
+ * peers while its `bad` section carries optional ones.
171
+ * - **An unparseable range or version with something resolved is skipped.**
172
+ * The check cannot judge it, and asserting "unsatisfied" on a comparison
173
+ * that was never performed would be a wrong answer rather than a gap.
174
+ * - **A workspace-linked provider counts.** Resolution is followed through
175
+ * whatever the edge names, including a workspace row.
176
+ *
177
+ * Known limits it does not paper over: yarn (see `supported`), the npm and
178
+ * bun root importer (see `unresolvedImporters`), and pnpm recording no peer
179
+ * declarations for workspace projects themselves — under pnpm a workspace
180
+ * package's *own* unsatisfied peers are not in the lockfile at all, and
181
+ * `pnpm peers check` does not report them either.
182
+ *
183
+ * @param lockfile - a lockfile parsed by `@effected/lockfiles`
184
+ * @returns the report; never fails
185
+ */
186
+ static run(lockfile, options) {
187
+ const keySupplied = options !== void 0 && "peerDependencyRules" in options;
188
+ const rules = options?.peerDependencyRules;
189
+ const axesUnapplied = rules !== void 0 && (rules.ignoreMissing.length > 0 || rules.allowAny.length > 0);
190
+ const rulesApplied = keySupplied && !axesUnapplied;
191
+ const allowed = rules?.allowedVersions ?? {};
192
+ const unverified = rulesApplied ? [] : ["peerRulesNotApplied"];
193
+ if (!supportsPeerResolution(lockfile.format)) return PeerCheck.make({
194
+ supported: false,
195
+ unsatisfied: [],
196
+ unresolvedImporters: [],
197
+ unverified
198
+ });
199
+ const byId = new Map(lockfile.packages.map((pkg) => [pkg.instanceId, pkg]));
200
+ const workspaceByPath = /* @__PURE__ */ new Map();
201
+ for (const pkg of lockfile.packages) if (pkg.isWorkspace && pkg.relativePath !== void 0) workspaceByPath.set(pkg.relativePath, pkg);
202
+ const rows = [];
203
+ const unresolved = [];
204
+ const seen = /* @__PURE__ */ new Set();
205
+ for (const importer of lockfile.importers) {
206
+ const roots = rootInstances(lockfile, importer.path, workspaceByPath, byId);
207
+ if (roots === void 0) {
208
+ unresolved.push(importer.path);
209
+ continue;
210
+ }
211
+ collect(importer.path, roots, byId, rows, seen, allowed);
212
+ }
213
+ if (lockfile.packages.some((pkg) => pkg.unresolvedEdges.length > 0)) unverified.push("unresolvedEdge");
214
+ return PeerCheck.make({
215
+ supported: true,
216
+ unsatisfied: rows,
217
+ unresolvedImporters: unresolved,
218
+ unverified
219
+ });
220
+ }
221
+ };
222
+ /**
223
+ * The instances an importer's dependencies resolved to, plus the importer's own
224
+ * workspace row when the lockfile records one.
225
+ *
226
+ * Returns `undefined` when the importer cannot be resolved at all, which the
227
+ * caller reports rather than treating as "no problems here".
228
+ *
229
+ * @internal
230
+ */
231
+ const rootInstances = (lockfile, importerPath, workspaceByPath, byId) => {
232
+ const own = workspaceByPath.get(importerPath);
233
+ if (own !== void 0) return [{
234
+ instance: own,
235
+ path: []
236
+ }];
237
+ const importer = lockfile.importer(importerPath);
238
+ if (importer._tag === "None") return void 0;
239
+ const walks = [];
240
+ let resolvable = false;
241
+ for (const dep of importer.value.dependencies) {
242
+ if (dep.version === void 0) continue;
243
+ const composed = byId.get(`${dep.name}@${dep.version}${dep.peerSuffix ?? ""}`);
244
+ if (composed === void 0) continue;
245
+ resolvable = true;
246
+ walks.push({
247
+ instance: composed,
248
+ path: [PeerParent.make({
249
+ name: composed.name,
250
+ version: composed.version
251
+ })]
252
+ });
253
+ }
254
+ if (!resolvable && importer.value.dependencies.length > 0) return void 0;
255
+ return walks;
256
+ };
257
+ /**
258
+ * Walk the resolution graph from an importer's roots, emitting a row for every
259
+ * unsatisfied peer declared anywhere along the way.
260
+ *
261
+ * @internal
262
+ */
263
+ const collect = (importerPath, roots, byId, rows, seen, allowed) => {
264
+ const visited = /* @__PURE__ */ new Set();
265
+ const queue = [...roots];
266
+ while (queue.length > 0) {
267
+ const current = queue.shift();
268
+ if (current === void 0) break;
269
+ if (visited.has(current.instance.instanceId)) continue;
270
+ visited.add(current.instance.instanceId);
271
+ for (const [peer, wanted] of Object.entries(current.instance.peerDependencies)) {
272
+ if (peer === "") continue;
273
+ const optional = current.instance.peerDependenciesMeta[peer]?.optional === true;
274
+ const verdict = judge(current.instance, peer, wanted, optional, byId);
275
+ if (verdict === void 0) continue;
276
+ if (suppressedByRule(allowed, current.instance.name, peer, verdict.found)) continue;
277
+ const key = `${importerPath}\u0000${peer}\u0000${current.instance.instanceId}`;
278
+ if (seen.has(key)) continue;
279
+ seen.add(key);
280
+ rows.push(UnsatisfiedPeer.make({
281
+ importer: importerPath,
282
+ dependency: peer,
283
+ wanted,
284
+ found: verdict.found,
285
+ optional,
286
+ parents: current.path
287
+ }));
288
+ }
289
+ for (const targetId of Object.values(current.instance.resolved)) {
290
+ const next = byId.get(targetId);
291
+ if (next === void 0 || visited.has(targetId)) continue;
292
+ queue.push({
293
+ instance: next,
294
+ path: [...current.path, PeerParent.make({
295
+ name: next.name,
296
+ version: next.version
297
+ })]
298
+ });
299
+ }
300
+ }
301
+ };
302
+ /**
303
+ * The parent name a `peerDependencyRules.allowedVersions` key names, with the
304
+ * parent's version stripped.
305
+ *
306
+ * @remarks
307
+ * **pnpm ignores the parent version in a rule key**, so
308
+ * `"react-dom@18.0.0>react"` suppresses a `react-dom@18.3.1` instance too.
309
+ * Replicating that is not optional: matching on the version would suppress a
310
+ * strictly smaller set than pnpm does, and every row in the difference is a
311
+ * false positive.
312
+ *
313
+ * Both spellings occur in the wild and both must work — parent-versioned, as
314
+ * `pnpm:export` materializes into `pnpm-workspace.yaml`, and unversioned, as a
315
+ * config-dependency plugin injects. A scoped name keeps its leading `@`, so the
316
+ * version separator is the LAST `@`, and only when it is not the first
317
+ * character.
318
+ *
319
+ * A key naming no parent at all (`"react"`, with no `>`) never reaches this
320
+ * function: {@link suppressedByRule} matches it against every parent, which is
321
+ * what pnpm does with it.
322
+ *
323
+ * @internal
324
+ */
325
+ const ruleParentName = (parent) => {
326
+ const at = parent.lastIndexOf("@");
327
+ return at > 0 ? parent.slice(0, at) : parent;
328
+ };
329
+ /**
330
+ * Whether a rule permits the version that actually resolved for this peer.
331
+ *
332
+ * @remarks
333
+ * Only `allowedVersions` is consulted. `ignoreMissing` and `allowAny` are
334
+ * carried through the seam **unmeasured and unwired** — an unmeasured
335
+ * suppression is exactly what produced the false positives this work removes,
336
+ * so no code acts on them until someone measures them. Non-empty, they make the
337
+ * whole report `"peerRulesNotApplied"` (see {@link PeerCheck.run}) rather than
338
+ * being ignored here.
339
+ *
340
+ * A rule cannot rescue a peer that resolved to nothing: with no version there
341
+ * is nothing for the rule to permit, and pnpm's own `missing` section is not
342
+ * suppressed by `allowedVersions` either.
343
+ *
344
+ * @internal
345
+ */
346
+ const suppressedByRule = (allowed, parentName, peer, found) => {
347
+ if (found === null) return false;
348
+ const version = SemVer.parseResult(found);
349
+ if (Result.isFailure(version)) return false;
350
+ for (const [key, permitted] of Object.entries(allowed)) {
351
+ const separator = key.indexOf(">");
352
+ if (separator === 0) continue;
353
+ if (separator === -1) {
354
+ if (key !== peer) continue;
355
+ } else {
356
+ if (key.slice(separator + 1) !== peer) continue;
357
+ if (ruleParentName(key.slice(0, separator)) !== parentName) continue;
358
+ }
359
+ const range = Range.parseResult(permitted);
360
+ if (Result.isFailure(range)) continue;
361
+ if (Range.satisfies(version.success, range.success)) return true;
362
+ }
363
+ return false;
364
+ };
365
+ /**
366
+ * Decide whether one declared peer is unsatisfied.
367
+ *
368
+ * Returns `undefined` for "satisfied, or not judgeable" — the two cases that
369
+ * produce no row. They are deliberately merged here and separated in the
370
+ * TSDoc on {@link PeerCheck.run}, since neither yields output.
371
+ *
372
+ * @internal
373
+ */
374
+ const judge = (instance, peer, wanted, optional, byId) => {
375
+ const providerId = instance.resolved[peer];
376
+ const provider = providerId === void 0 ? void 0 : byId.get(providerId);
377
+ if (provider === void 0) {
378
+ if (optional) return void 0;
379
+ if (instance.unresolvedEdges.includes(peer)) return void 0;
380
+ return { found: null };
381
+ }
382
+ if (provider.isWorkspace) return void 0;
383
+ const range = Range.parseResult(wanted);
384
+ const version = SemVer.parseResult(provider.version);
385
+ if (Result.isFailure(range) || Result.isFailure(version)) return void 0;
386
+ if (Range.satisfies(version.success, range.success)) return void 0;
387
+ return { found: provider.version };
388
+ };
389
+
390
+ //#endregion
391
+ export { PeerCheck, PeerParent, UnsatisfiedPeer };
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.
8
+ Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile, check it for unsatisfied peer dependencies and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.
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
@@ -142,6 +142,43 @@ const program = Effect.gen(function* () {
142
142
 
143
143
  A specifier the workspace cannot answer fails typed as `UnresolvedDependencyError`: at the manifest level "no catalog entry" means the manifest cannot be projected to concrete ranges.
144
144
 
145
+ ## Peer dependency checking
146
+
147
+ `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.
148
+
149
+ ```ts
150
+ import { LockfileReader, PeerCheck, WorkspaceCatalogs } from "@effected/workspaces";
151
+ import { Effect } from "effect";
152
+
153
+ const program = Effect.gen(function* () {
154
+ const reader = yield* LockfileReader;
155
+ const catalogs = yield* WorkspaceCatalogs;
156
+
157
+ const lockfile = yield* reader.read();
158
+ // Presence of the key is the assertion — see below.
159
+ const report = PeerCheck.run(lockfile, { peerDependencyRules: yield* catalogs.peerDependencyRules() });
160
+
161
+ return {
162
+ clean:
163
+ report.supported &&
164
+ report.unresolvedImporters.length === 0 &&
165
+ report.unverified.length === 0 &&
166
+ report.required.length === 0,
167
+ required: report.required.length,
168
+ };
169
+ });
170
+ // { clean: whether the workspace is proven clean, required: count of non-optional findings }
171
+ ```
172
+
173
+ **An empty `unsatisfied` is not the same as clean**, and reading it that way is the mistake this report's shape exists to prevent. Three other fields carry the difference between "nothing is wrong" and "nothing was checked", and a gate must read all four:
174
+
175
+ - `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.
176
+ - `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.
177
+ - `unverified` says why the report is not a complete answer: `"peerRulesNotApplied"` when the suppression policy could not be applied, `"unresolvedEdge"` when an instance records an edge the model could not name. Both mean fail closed.
178
+ - `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.
179
+
180
+ `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"`. Only `allowedVersions` is applied — rules populating `ignoreMissing` or `allowAny` describe a policy this package does not replicate, so they fail closed through the same reason rather than being silently ignored. All three key spellings pnpm accepts are honoured: `parent@version>peer` (what `pnpm:export` writes), `parent>peer` (what a config-dependency plugin injects) and a bare `peer`, which pnpm applies to every parent that declares it.
181
+
145
182
  ## The synchronous escape hatch
146
183
 
147
184
  Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply. On Node you do not have to write them: the `@effected/workspaces/node-sync` subpath exports `nodeSyncOps`, the ready-made `node:fs` and `node:path` bindings, so adopting the sync path is one extra import.
@@ -242,6 +279,8 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
242
279
  - `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field.
243
280
  - `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages; `releaseAgeGate()` assembles the effective `@effected/npm` `ReleaseAgeGate` from inline `pnpm-workspace.yaml` release-age keys and replayed hook contributions, strictest-wins, in the same pass as the catalogs.
244
281
  - `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
282
+ - `PeerCheck` — unsatisfied peer-dependency detection as a pure, total value over a parsed lockfile, with `UnsatisfiedPeer` and `PeerParent` as the report's rows. Read `supported`, `unresolvedImporters` and `unverified` alongside `unsatisfied`: an empty finding list is a clean bill of health only when those three say so.
283
+ - `NoPeerDependencyRules` / `PeerDependencyRules` — the effective pnpm suppression policy `WorkspaceCatalogs.peerDependencyRules()` assembles, and the "I assert none apply" value for callers that have checked.
245
284
  - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
246
285
  - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). No composite provides one: pick `PublishabilityDetector.layerNpm` (standard npm semantics) or `.layerNone` (nothing publishes) and provide it explicitly.
247
286
  - `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
@@ -90,8 +90,12 @@ var VersioningStrategy = class VersioningStrategy extends Schema.Class("Versioni
90
90
  const discovery = yield* WorkspaceDiscovery;
91
91
  const publishability = yield* PublishabilityDetector;
92
92
  const packages = yield* discovery.listPackages();
93
+ const detected = yield* Effect.forEach(packages, (candidate) => publishability.detect(candidate).pipe(Effect.map((targets) => ({
94
+ name: candidate.name,
95
+ publishable: targets.length > 0
96
+ }))), { concurrency: 10 });
93
97
  const publishable = [];
94
- for (const candidate of packages) if ((yield* publishability.detect(candidate)).length > 0) publishable.push(candidate.name);
98
+ for (const candidate of detected) if (candidate.publishable) publishable.push(candidate.name);
95
99
  return VersioningStrategy.classify({
96
100
  packages: publishable,
97
101
  ...options?.fixedGroups !== void 0 && { fixedGroups: options.fixedGroups }
@@ -211,6 +211,41 @@ const inlineReleaseAge = (document) => {
211
211
  cause
212
212
  })));
213
213
  };
214
+ /**
215
+ * The `peerDependencyRules` a `pnpm-workspace.yaml` declares inline — the half
216
+ * `pnpm:export` materializes into the file, as opposed to the half a config
217
+ * dependency injects at replay time.
218
+ *
219
+ * @remarks
220
+ * **Tolerant, unlike the catalog blocks**, and the asymmetry is deliberate: a
221
+ * malformed catalog block must hard-fail because a silently-empty catalog makes
222
+ * every dependency look newly added, whereas a malformed rules block costs only
223
+ * suppression — the failure mode is reporting a peer pnpm would have hidden,
224
+ * which is visible and safe. Failing the whole assembly over it would take the
225
+ * catalogs down with it.
226
+ *
227
+ * Every axis is read independently, so a malformed `ignoreMissing` does not
228
+ * discard a well-formed `allowedVersions`.
229
+ */
230
+ const EMPTY_PEER_RULES = {
231
+ allowedVersions: {},
232
+ ignoreMissing: [],
233
+ allowAny: []
234
+ };
235
+ const inlinePeerDependencyRules = (document) => {
236
+ if (!isObject(document) || !isObject(document.peerDependencyRules)) return EMPTY_PEER_RULES;
237
+ const block = document.peerDependencyRules;
238
+ const allowedVersions = {};
239
+ if (isObject(block.allowedVersions)) {
240
+ for (const [key, value] of Object.entries(block.allowedVersions)) if (typeof value === "string") allowedVersions[key] = value;
241
+ }
242
+ const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
243
+ return {
244
+ allowedVersions,
245
+ ignoreMissing: strings(block.ignoreMissing),
246
+ allowAny: strings(block.allowAny)
247
+ };
248
+ };
214
249
  /** A hard-fail catalog-assembly failure naming the malformed part of a `workspaces` field. */
215
250
  const malformed = (source, path, detail) => new CatalogAssemblyError({
216
251
  source,
@@ -343,6 +378,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
343
378
  let injected;
344
379
  let inlineGate = {};
345
380
  let hookGate = {};
381
+ let peerDependencyRules = EMPTY_PEER_RULES;
346
382
  if (hasPnpmWorkspace) {
347
383
  const text = yield* fs.readFileString(workspaceYaml).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
348
384
  source: "manifest",
@@ -357,15 +393,17 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
357
393
  yield* validatePnpmWorkspaceCatalogs(document);
358
394
  inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
359
395
  inlineGate = yield* inlineReleaseAge(document);
360
- const injection = yield* hooks.inject(root, configDependenciesOf(document), inline.entries);
396
+ const injection = yield* hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document));
361
397
  injected = CatalogSet.fromCatalogs(injection.catalogs);
362
398
  hookGate = injection.releaseAge;
399
+ peerDependencyRules = injection.peerDependencyRules;
363
400
  } else {
364
401
  const manifestPath = path.join(root, "package.json");
365
402
  if (!(yield* probeExists(manifestPath))) return {
366
403
  catalogs: fromLockfile,
367
404
  releaseAgeGate: ReleaseAgeGate.combine(),
368
- importerVersions
405
+ importerVersions,
406
+ peerDependencyRules: EMPTY_PEER_RULES
369
407
  };
370
408
  const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
371
409
  source: "manifest",
@@ -385,7 +423,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
385
423
  return {
386
424
  catalogs: assembled,
387
425
  releaseAgeGate,
388
- importerVersions
426
+ importerVersions,
427
+ peerDependencyRules
389
428
  };
390
429
  });
391
430
  const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
@@ -397,6 +436,9 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
397
436
  resolveSpecifier: Effect.fn("WorkspaceCatalogs.resolveSpecifier")(function* (dependency, specifier) {
398
437
  return (yield* memo).catalogs.resolveSpecifier(dependency, specifier);
399
438
  }),
439
+ peerDependencyRules: Effect.fn("WorkspaceCatalogs.peerDependencyRules")(function* () {
440
+ return (yield* memo).peerDependencyRules;
441
+ }),
400
442
  releaseAgeGate: Effect.fn("WorkspaceCatalogs.releaseAgeGate")(function* () {
401
443
  return (yield* memo).releaseAgeGate;
402
444
  }),
@@ -483,6 +525,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
483
525
  return {
484
526
  set: () => unstubbed("set"),
485
527
  resolveSpecifier: set !== void 0 ? (dependency, specifier) => Effect.map(set(), (catalogs) => catalogs.resolveSpecifier(dependency, specifier)) : () => unstubbed("resolveSpecifier"),
528
+ peerDependencyRules: () => unstubbed("peerDependencyRules"),
486
529
  releaseAgeGate: () => unstubbed("releaseAgeGate"),
487
530
  importerVersions: () => unstubbed("importerVersions"),
488
531
  ...overrides
package/WorkspacesSync.js CHANGED
@@ -196,8 +196,11 @@ const getWorkspacePackagesSync = (root, options) => {
196
196
  const compiled = Effect.runSyncExit(GlobSet.compile(patterns));
197
197
  if (Exit.isFailure(compiled)) return [];
198
198
  const globs = compiled.value;
199
+ const excludes = globs.excludes;
200
+ const isExcluded = excludes.length === 0 ? (_) => false : (relative) => excludes.some((exclude) => exclude.matches(relative));
199
201
  const included = /* @__PURE__ */ new Map();
200
202
  for (const literal of globs.literals) {
203
+ if (isExcluded(literal)) continue;
201
204
  const absolute = path.join(root, literal);
202
205
  if (isPackage(options, absolute)) included.set(literal, absolute);
203
206
  }
@@ -224,7 +227,7 @@ const getWorkspacePackagesSync = (root, options) => {
224
227
  stopped = true;
225
228
  break;
226
229
  }
227
- if (wildcard.matches(relative) && isPackage(options, absolute)) included.set(relative, absolute);
230
+ if (wildcard.matches(relative) && !isExcluded(relative) && isPackage(options, absolute)) included.set(relative, absolute);
228
231
  if (!wildcard.crossesSegments) continue;
229
232
  traversal.push(current, relative, absolute);
230
233
  }
@@ -233,7 +236,6 @@ const getWorkspacePackagesSync = (root, options) => {
233
236
  const members = [];
234
237
  for (const [relativePath, absolute] of [...included.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
235
238
  if (relativePath === "." || absolute === root) continue;
236
- if (globs.excludes.some((exclude) => exclude.matches(relativePath))) continue;
237
239
  const pkg = readPackageSync(options, root, absolute, relativePath);
238
240
  if (pkg !== null) members.push(pkg);
239
241
  }
package/index.d.ts CHANGED
@@ -797,6 +797,52 @@ declare class ChangeDetector extends ChangeDetector_base {
797
797
  }
798
798
  //#endregion
799
799
  //#region src/ConfigDependencyHooks.d.ts
800
+ /**
801
+ * pnpm's `peerDependencyRules` block — the suppression policy pnpm applies
802
+ * **after** computing peer violations, in pnpm's own shape.
803
+ *
804
+ * @remarks
805
+ * The shape is pnpm's rather than ours because that is what comes back off the
806
+ * threaded config: measured against `@savvy-web/pnpm-plugin-silk@0.27.0`, a
807
+ * replayed hook returns `{ allowedVersions, ignoreMissing, allowAny }` intact,
808
+ * needing no reshaping.
809
+ *
810
+ * Two keys are **carried but not yet consumed**. `ignoreMissing` and
811
+ * `allowAny` are separate suppression axes that have not been measured, and an
812
+ * unmeasured suppression is precisely what produced the false positives this
813
+ * seam exists to remove — so they travel through the seam and no kit code acts
814
+ * on them. Only `allowedVersions` is consumed today.
815
+ *
816
+ * `allowedVersions` keys come in two spellings in the wild, and both must be
817
+ * handled: parent-versioned (`"@effect/ai-anthropic@4.0.0-rc.109>effect"`, as
818
+ * `pnpm:export` materializes them into `pnpm-workspace.yaml`) and unversioned
819
+ * (`"@effect/vitest>vitest"`, as a config-dependency plugin injects them).
820
+ *
821
+ * @public
822
+ */
823
+ interface PeerDependencyRules {
824
+ /** `parent>peer` → the peer version or range the rule permits. */
825
+ readonly allowedVersions: Readonly<Record<string, string>>;
826
+ /** Peer names whose absence pnpm does not report. Carried, not consumed. */
827
+ readonly ignoreMissing: ReadonlyArray<string>;
828
+ /** Peer names for which any version is accepted. Carried, not consumed. */
829
+ readonly allowAny: ReadonlyArray<string>;
830
+ }
831
+ /**
832
+ * The empty {@link PeerDependencyRules}: every axis present and empty.
833
+ *
834
+ * @remarks
835
+ * Exported so that "I assert this workspace's rules are empty" is **one token**
836
+ * rather than three hand-written empty axes. That matters where the distinction
837
+ * is load-bearing — supplying rules asserts they were looked up, while omitting
838
+ * them asserts nothing — and a caller spelling the object out by hand will
839
+ * eventually fill two of the three axes and mean the third.
840
+ *
841
+ * Frozen, since it is shared.
842
+ *
843
+ * @public
844
+ */
845
+ declare const NoPeerDependencyRules: PeerDependencyRules;
800
846
  /**
801
847
  * The result of replaying a workspace's `configDependencies` hooks: the catalogs
802
848
  * the hooks yield, and the release-age gate contribution they leave on the
@@ -816,6 +862,24 @@ interface HookInjection {
816
862
  readonly catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>;
817
863
  /** The release-age gate contribution the replayed hooks leave on the config. */
818
864
  readonly releaseAge: PartialReleaseAgeGate;
865
+ /**
866
+ * The **effective** peer-dependency rules: the seeded workspace-file rules
867
+ * with every replayed hook's contribution threaded over them.
868
+ *
869
+ * @remarks
870
+ * Effective rather than hook-only because the rules are **seeded** into the
871
+ * threaded config and the hooks merge onto them, exactly as pnpm seeds its
872
+ * own config and takes back what the hooks return. That is deliberate: a
873
+ * kit-owned merge function would be a second implementation of a rule this
874
+ * seam already enforces, and the two would drift the first time pnpm changed
875
+ * its threading.
876
+ *
877
+ * Measured caveat, and **not a bug to fix**: this repo's plugin *merges*
878
+ * onto the seeded rules; another plugin could overwrite them. Under seeding
879
+ * that is pnpm's own behaviour reproduced — a hook that overwrites
880
+ * overwrites for pnpm too — so do not "repair" it into a merger.
881
+ */
882
+ readonly peerDependencyRules: PeerDependencyRules;
819
883
  }
820
884
  /**
821
885
  * The {@link ConfigDependencyHooks} service shape.
@@ -853,8 +917,12 @@ interface ConfigDependencyHooksShape {
853
917
  * @param configDependencies - The `configDependencies` map (name →
854
918
  * version+integrity) declared in `pnpm-workspace.yaml`.
855
919
  * @param seed - The inline catalogs, as `catalog name → dependency → range`.
920
+ * @param rules - The workspace file's `peerDependencyRules`, seeded into the
921
+ * threaded config so hooks merge onto them rather than replacing them.
922
+ * Omitted means "the workspace file declares none", which is different
923
+ * from "nobody looked" — the caller owns that distinction.
856
924
  */
857
- readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string>>>>) => Effect.Effect<HookInjection, CatalogAssemblyError>;
925
+ readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string>>>>, rules?: PeerDependencyRules) => Effect.Effect<HookInjection, CatalogAssemblyError>;
858
926
  }
859
927
  declare const ConfigDependencyHooks_base: Context.ServiceClass<ConfigDependencyHooks, "@effected/workspaces/ConfigDependencyHooks", ConfigDependencyHooksShape>;
860
928
  /**
@@ -1444,6 +1512,219 @@ declare class LockfileReader extends LockfileReader_base {
1444
1512
  static readonly layerTest: (overrides?: Partial<LockfileReaderShape>) => Layer.Layer<LockfileReader>;
1445
1513
  }
1446
1514
  //#endregion
1515
+ //#region src/PeerCheck.d.ts
1516
+ /**
1517
+ * Why a report is not a complete answer.
1518
+ *
1519
+ * @remarks
1520
+ * Closed at exactly two, by measurement rather than by guess:
1521
+ *
1522
+ * - `"peerRulesNotApplied"` — the effective suppression policy was not applied,
1523
+ * so pnpm's post-hoc suppression could not be replicated and some reported
1524
+ * rows may be ones pnpm hides. **Omitting the option always produces this**,
1525
+ * because "nobody looked" and "I looked and there are none" are different
1526
+ * facts. Supplied rules with a non-empty `ignoreMissing` or `allowAny` also
1527
+ * produce it: only `allowedVersions` is applied, so an unimplemented axis
1528
+ * degrades to fail-closed rather than to a wrong answer.
1529
+ * - `"unresolvedEdge"` — some instance records an edge this model could not
1530
+ * name (`ResolvedPackage.unresolvedEdges`), so a peer that edge satisfies
1531
+ * cannot be verified either way.
1532
+ *
1533
+ * Both mean **fail closed**: a gate should treat an unverified report as "not
1534
+ * proven clean" rather than as a pass.
1535
+ *
1536
+ * @public
1537
+ */
1538
+ type UnverifiedReason = "peerRulesNotApplied" | "unresolvedEdge";
1539
+ /**
1540
+ * Options for {@link PeerCheck.run}.
1541
+ *
1542
+ * @remarks
1543
+ * **Presence of `peerDependencyRules` is the assertion; its contents are the
1544
+ * value.** Supplying `NoPeerDependencyRules` asserts the workspace has none and
1545
+ * yields a verified report; omitting the key entirely says nothing was looked
1546
+ * up and always yields `"peerRulesNotApplied"`. The two are deliberately
1547
+ * different results, because a gate must be able to tell "clean" from
1548
+ * "unchecked".
1549
+ *
1550
+ * **Only `allowedVersions` is applied.** Rules whose `ignoreMissing` or
1551
+ * `allowAny` is non-empty describe a suppression policy this module does not
1552
+ * implement, so they also yield `"peerRulesNotApplied"` — the unimplemented
1553
+ * axes fail closed instead of being silently ignored.
1554
+ *
1555
+ * @public
1556
+ */
1557
+ interface PeerCheckOptions {
1558
+ /** The workspace's effective rules, from `WorkspaceCatalogs.peerDependencyRules()`. */
1559
+ readonly peerDependencyRules?: PeerDependencyRules;
1560
+ }
1561
+ declare const PeerParent_base: Schema.Class<PeerParent, Schema.Struct<{
1562
+ /** The package name. */
1563
+ readonly name: Schema.NonEmptyString;
1564
+ /** The resolved version of that package. */
1565
+ readonly version: Schema.String;
1566
+ }>, {}>;
1567
+ /**
1568
+ * One link in the chain from an importer to the package that declared an
1569
+ * unsatisfied peer.
1570
+ *
1571
+ * @remarks
1572
+ * Mirrors the shape `pnpm peers check --json` reports, so the two can be
1573
+ * compared directly.
1574
+ *
1575
+ * @public
1576
+ */
1577
+ declare class PeerParent extends PeerParent_base {}
1578
+ declare const UnsatisfiedPeer_base: Schema.Class<UnsatisfiedPeer, Schema.Struct<{
1579
+ /** The importer path the problem belongs to (`"."` for the root). */
1580
+ readonly importer: Schema.NonEmptyString;
1581
+ /** The peer dependency's name. */
1582
+ readonly dependency: Schema.NonEmptyString;
1583
+ /** The range the declaring package asked for. */
1584
+ readonly wanted: Schema.String;
1585
+ /** The version that resolved, or `null` when nothing resolved. */
1586
+ readonly found: Schema.NullOr<Schema.String>;
1587
+ /** Whether the declaring package marked the peer optional. */
1588
+ readonly optional: Schema.Boolean;
1589
+ /** The path from the importer to the declaring package. */
1590
+ readonly parents: Schema.$Array<typeof PeerParent>;
1591
+ }>, {}>;
1592
+ /**
1593
+ * One peer dependency that is declared but not satisfied.
1594
+ *
1595
+ * @remarks
1596
+ * `found` carries the version that actually resolved, or `null` when nothing
1597
+ * resolved at all. There is deliberately **no separate discriminant** between
1598
+ * "missing" and "unmet": `found === null` is the whole distinction, and a
1599
+ * renderer reads it directly.
1600
+ *
1601
+ * `optional` must be respected by any gate — an unsatisfied *optional* peer is
1602
+ * normal and should not fail a build, which is why the flag travels with the
1603
+ * row rather than being filtered out here. {@link PeerCheck.required} is the
1604
+ * convenience for the common case.
1605
+ *
1606
+ * `parents` is the path from the importer down to the package that declared
1607
+ * the peer, nearest-to-the-importer first. It is a path rather than a single
1608
+ * package because a peer declared by a transitive dependency is still that
1609
+ * importer's problem, and the chain is what makes the report actionable.
1610
+ *
1611
+ * A package an importer reaches by more than one path yields **one** row, not
1612
+ * one per path, carrying the path the walk reached first — which is what
1613
+ * `pnpm peers check` reports for the same graph. Read `parents` as *a* route to
1614
+ * the declaring package, never as the complete set of them.
1615
+ *
1616
+ * @public
1617
+ */
1618
+ declare class UnsatisfiedPeer extends UnsatisfiedPeer_base {}
1619
+ declare const PeerCheck_base: Schema.Class<PeerCheck, Schema.Struct<{
1620
+ /**
1621
+ * Whether the lockfile's format records peer resolution at all.
1622
+ *
1623
+ * `false` for yarn: yarn resolves peers **virtually**, giving a
1624
+ * peer-bearing package one `@virtual:` locator per consumer, and the
1625
+ * lockfile does not record which virtual instance satisfied which peer.
1626
+ * The answer is not recoverable, so it is not fabricated — `unsatisfied`
1627
+ * is empty and this flag says why.
1628
+ */
1629
+ readonly supported: Schema.Boolean;
1630
+ /** Every unsatisfied peer found, optional ones included. */
1631
+ readonly unsatisfied: Schema.$Array<typeof UnsatisfiedPeer>;
1632
+ /**
1633
+ * Importers whose dependencies could not be resolved to instances, so no
1634
+ * verdict was reached for them.
1635
+ *
1636
+ * @remarks
1637
+ * In practice this is the **root importer under npm and bun**: neither
1638
+ * records a resolved version per importer dependency, and neither emits a
1639
+ * package row for the root, so there is nothing to join on. pnpm records a
1640
+ * version per importer dependency and is unaffected.
1641
+ *
1642
+ * Reported rather than silently skipped, for the same reason `supported`
1643
+ * exists: a gate that sees no rows is entitled to know whether that means
1644
+ * "clean" or "not looked at".
1645
+ */
1646
+ readonly unresolvedImporters: Schema.$Array<Schema.String>;
1647
+ /**
1648
+ * Why this report is not a complete answer, or empty when it is.
1649
+ *
1650
+ * @remarks
1651
+ * See {@link UnverifiedReason}. A gate must treat a non-empty `unverified`
1652
+ * as **not proven clean** rather than as a pass: both reasons mean some
1653
+ * finding may be missing or spurious, and failing closed is the requirement.
1654
+ */
1655
+ readonly unverified: Schema.$Array<Schema.Literals<readonly ["peerRulesNotApplied", "unresolvedEdge"]>>;
1656
+ }>, {}>;
1657
+ /**
1658
+ * The result of checking a lockfile for unsatisfied peer dependencies.
1659
+ *
1660
+ * @remarks
1661
+ * A report rather than a bare array, because an empty array is the most
1662
+ * dangerous success shape this domain has: it is indistinguishable from "this
1663
+ * format cannot be checked". `supported` and `unresolvedImporters` make the
1664
+ * difference legible, so a consumer cannot read a limitation as a clean bill
1665
+ * of health.
1666
+ *
1667
+ * Pure and total — construct it with {@link PeerCheck.run}, which never fails.
1668
+ *
1669
+ * @example
1670
+ * ```ts
1671
+ * import { PeerCheck } from "@effected/workspaces";
1672
+ * import { Lockfile } from "@effected/lockfiles";
1673
+ * import { Effect } from "effect";
1674
+ *
1675
+ * const program = Effect.gen(function* () {
1676
+ * const lockfile = yield* Lockfile.parse(text, { format: "pnpm" });
1677
+ * const report = PeerCheck.run(lockfile);
1678
+ * return report.supported ? report.required : [];
1679
+ * });
1680
+ * ```
1681
+ *
1682
+ * @public
1683
+ */
1684
+ declare class PeerCheck extends PeerCheck_base {
1685
+ /** The unsatisfied peers a gate should act on — the non-optional ones. */
1686
+ get required(): ReadonlyArray<UnsatisfiedPeer>;
1687
+ /**
1688
+ * Compute the unsatisfied peer dependencies of a parsed lockfile.
1689
+ *
1690
+ * @remarks
1691
+ * Pure, total and format-free: no IO, no error channel, and no knowledge of
1692
+ * which package manager wrote the file. Range satisfaction is
1693
+ * `@effected/semver`'s, never hand-rolled.
1694
+ *
1695
+ * The walk starts at each importer's resolved dependencies and follows
1696
+ * `resolved` edges, so a peer declared by a *transitive* dependency is
1697
+ * attributed to the importer that pulls it in, with the chain in
1698
+ * `parents` — matching how `pnpm peers check` attributes them.
1699
+ *
1700
+ * Three judgements are deliberate:
1701
+ *
1702
+ * - **A required peer with nothing resolved is reported** (`found: null`),
1703
+ * whether or not the declared range is parseable — a peer with no
1704
+ * provider is a fact about the graph that needs no range arithmetic.
1705
+ * - **An ABSENT optional peer is not reported at all**, because that is
1706
+ * what optional means. An optional peer resolved at the *wrong* version
1707
+ * still is, carrying `optional: true`. Both halves match
1708
+ * `pnpm peers check`, whose `missing` section contains only required
1709
+ * peers while its `bad` section carries optional ones.
1710
+ * - **An unparseable range or version with something resolved is skipped.**
1711
+ * The check cannot judge it, and asserting "unsatisfied" on a comparison
1712
+ * that was never performed would be a wrong answer rather than a gap.
1713
+ * - **A workspace-linked provider counts.** Resolution is followed through
1714
+ * whatever the edge names, including a workspace row.
1715
+ *
1716
+ * Known limits it does not paper over: yarn (see `supported`), the npm and
1717
+ * bun root importer (see `unresolvedImporters`), and pnpm recording no peer
1718
+ * declarations for workspace projects themselves — under pnpm a workspace
1719
+ * package's *own* unsatisfied peers are not in the lockfile at all, and
1720
+ * `pnpm peers check` does not report them either.
1721
+ *
1722
+ * @param lockfile - a lockfile parsed by `@effected/lockfiles`
1723
+ * @returns the report; never fails
1724
+ */
1725
+ static run(lockfile: Lockfile, options?: PeerCheckOptions): PeerCheck;
1726
+ }
1727
+ //#endregion
1447
1728
  //#region src/Publishability.d.ts
1448
1729
  declare const PublishTarget_base: Schema.Class<PublishTarget, Schema.Struct<{
1449
1730
  /** The package name being published. */
@@ -1479,7 +1760,16 @@ declare class PublishTarget extends PublishTarget_base {}
1479
1760
  * @public
1480
1761
  */
1481
1762
  interface PublishabilityDetectorShape {
1482
- /** The publish targets for a package; empty means it does not publish. */
1763
+ /**
1764
+ * The publish targets for a package; empty means it does not publish.
1765
+ *
1766
+ * @remarks
1767
+ * `VersioningStrategy.detect` probes a whole workspace by invoking this
1768
+ * concurrently — up to ten packages in flight at once, in no guaranteed
1769
+ * order. An overriding implementation backed by shared mutable state or a
1770
+ * rate-limited client must tolerate that interleaving itself; the caller
1771
+ * does not serialize on its behalf.
1772
+ */
1483
1773
  readonly detect: (pkg: WorkspacePackage) => Effect.Effect<ReadonlyArray<PublishTarget>>;
1484
1774
  }
1485
1775
  declare const PublishabilityDetector_base: Context.ServiceClass<PublishabilityDetector, "@effected/workspaces/PublishabilityDetector", PublishabilityDetectorShape>;
@@ -2126,6 +2416,26 @@ interface WorkspaceCatalogsShape {
2126
2416
  * workspace) has no release-age keys, so the gate is the inert zero gate.
2127
2417
  */
2128
2418
  readonly releaseAgeGate: () => Effect.Effect<ReleaseAgeGate, CatalogAssemblyFailure>;
2419
+ /**
2420
+ * The workspace's **effective** `peerDependencyRules` — pnpm's post-hoc
2421
+ * suppression policy, which the lockfile does not record at all.
2422
+ *
2423
+ * @remarks
2424
+ * Assembled from the same single read and hook replay as `set`, and memoized
2425
+ * with it. The `pnpm-workspace.yaml` block (what `pnpm:export` materializes)
2426
+ * is **seeded** into the replayed config and the config-dependency hooks
2427
+ * merge onto it, so one object carries both sources — the kit never merges
2428
+ * them itself, because that would be a second implementation of a rule the
2429
+ * hook seam already enforces.
2430
+ *
2431
+ * A consumer needs these to avoid **reporting** what pnpm suppresses: pnpm
2432
+ * computes the same peer violations and then hides the ones a rule allows,
2433
+ * so a checker without them reports findings pnpm calls clean.
2434
+ *
2435
+ * `ignoreMissing` and `allowAny` are carried but unmeasured — no kit code
2436
+ * acts on them today.
2437
+ */
2438
+ readonly peerDependencyRules: () => Effect.Effect<PeerDependencyRules, CatalogAssemblyFailure>;
2129
2439
  /**
2130
2440
  * Each importer's dependency-name → resolved-version map, as the manager's
2131
2441
  * lockfile records it. Read from the same single lockfile read as `set`, and
@@ -3072,5 +3382,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
3072
3382
  */
3073
3383
  declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
3074
3384
  //#endregion
3075
- export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
3385
+ export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, NoPeerDependencyRules, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PeerCheck, type PeerCheckOptions, type PeerDependencyRules, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, UnsatisfiedPeer, type UnverifiedReason, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
3076
3386
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -3,9 +3,10 @@ import { WORKSPACE_MARKERS, WorkspaceRoot, WorkspaceRootNotFoundError } from "./
3
3
  import { PackageNotFoundError, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspacePatternError } from "./WorkspaceDiscovery.js";
4
4
  import { CyclicDependencyError, DependencyGraph } from "./DependencyGraph.js";
5
5
  import { ChangeDetectionError, ChangeDetectionOptions, ChangeDetector } from "./ChangeDetector.js";
6
- import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
6
+ import { ConfigDependencyHooks, NoPeerDependencyRules } from "./ConfigDependencyHooks.js";
7
7
  import { DetectedPackageManager, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName } from "./PackageManagerName.js";
8
8
  import { LockfileReadError, LockfileReader } from "./LockfileReader.js";
9
+ import { PeerCheck, PeerParent, UnsatisfiedPeer } from "./PeerCheck.js";
9
10
  import { PublishTarget, PublishabilityDetector } from "./Publishability.js";
10
11
  import { ReleaseTag, TagStyle, TrackingTag, classifyTag } from "./ReleaseTag.js";
11
12
  import { VersioningStrategy, VersioningStrategyType } from "./VersioningStrategy.js";
@@ -15,4 +16,4 @@ import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
15
16
  import { Workspaces } from "./Workspaces.js";
16
17
  import { findWorkspaceRootSync, getWorkspacePackagesSync } from "./WorkspacesSync.js";
17
18
 
18
- export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
19
+ export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, NoPeerDependencyRules, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PeerCheck, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, UnsatisfiedPeer, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
@@ -18,6 +18,8 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
18
18
  if (!isValidMaxDepth(maxDepth)) return yield* Effect.die(/* @__PURE__ */ new Error(`enumerate: ${badMaxDepthMessage(maxDepth)}`));
19
19
  const isPackage = (absolute) => fs.exists(path.join(absolute, "package.json")).pipe(Effect.orElseSucceed(() => false));
20
20
  const isDirectory = (absolute) => fs.stat(absolute).pipe(Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false));
21
+ const excludes = globs.excludes;
22
+ const isExcluded = excludes.length === 0 ? (_) => false : (relative) => excludes.some((exclude) => exclude.matches(relative));
21
23
  const included = /* @__PURE__ */ new Map();
22
24
  /** A shared-traversal stop, materialized as this module's failure record. */
23
25
  const failureOf = (stop, pattern) => ({
@@ -26,6 +28,7 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
26
28
  detail: stop.detail
27
29
  });
28
30
  for (const literal of globs.literals) {
31
+ if (isExcluded(literal)) continue;
29
32
  const absolute = path.join(root, literal);
30
33
  if (yield* isPackage(absolute)) included.set(literal, absolute);
31
34
  }
@@ -53,20 +56,17 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
53
56
  const absolute = path.join(frame.absolute, entry);
54
57
  if (!(yield* isDirectory(absolute))) continue;
55
58
  if (wildcard.crossesSegments && !traversal.admits(frame)) return yield* Effect.fail(failureOf(traversal.depthStop(), wildcard.source));
56
- if (wildcard.matches(relative) && (yield* isPackage(absolute))) included.set(relative, absolute);
59
+ if (wildcard.matches(relative) && !isExcluded(relative) && (yield* isPackage(absolute))) included.set(relative, absolute);
57
60
  if (!wildcard.crossesSegments) continue;
58
61
  traversal.push(frame, relative, absolute);
59
62
  }
60
63
  }
61
64
  }
62
65
  const results = [];
63
- for (const [relativePath, absolute] of included) {
64
- if (globs.excludes.some((exclude) => exclude.matches(relativePath))) continue;
65
- results.push({
66
- relativePath,
67
- path: absolute
68
- });
69
- }
66
+ for (const [relativePath, absolute] of included) results.push({
67
+ relativePath,
68
+ path: absolute
69
+ });
70
70
  results.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
71
71
  return results;
72
72
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
6
6
  "keywords": [
@@ -49,7 +49,7 @@
49
49
  "@effected/commands": "^0.5.0",
50
50
  "@effected/git": "^0.9.0",
51
51
  "@effected/glob": "^0.4.0",
52
- "@effected/lockfiles": "^0.5.1",
52
+ "@effected/lockfiles": "^0.6.0",
53
53
  "@effected/npm": "^0.11.0",
54
54
  "@effected/package-json": "^0.10.2",
55
55
  "@effected/semver": "^0.5.0",