@effected/workspaces 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LockfileReader.js CHANGED
@@ -27,6 +27,8 @@ var LockfileReadError = class extends Schema.TaggedErrorClass()("LockfileReadErr
27
27
  return `Cannot read ${this.format} lockfile at ${this.lockfilePath}`;
28
28
  }
29
29
  };
30
+ /** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
31
+ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`LockfileReader.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
30
32
  /**
31
33
  * Reads and parses the workspace's lockfile.
32
34
  *
@@ -112,6 +114,74 @@ var LockfileReader = class LockfileReader extends Context.Service()("@effected/w
112
114
  * `const` and reuse it.
113
115
  */
114
116
  static layer = (options) => Layer.effect(LockfileReader, LockfileReader.make(options));
117
+ /**
118
+ * A test double satisfying the full {@link LockfileReaderShape} with no
119
+ * filesystem, root walk, or package-manager detection.
120
+ *
121
+ * @remarks
122
+ * There is **no honest default lockfile**: an empty one that looks like a
123
+ * legitimate answer is indistinguishable from "this workspace resolves
124
+ * nothing" — the silent-empty failure class this package documents on the
125
+ * live paths — so `read` **dies** with an instructive defect until stubbed.
126
+ *
127
+ * The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
128
+ * derived-from-the-primary rule: when a `read` override is supplied,
129
+ * `resolvedVersion` answers as the live service does — the **first** entry
130
+ * of `lockfile.packagesNamed(name)` in lockfile order, `Option.none()` on a
131
+ * miss — so the two stay consistent by construction. `integrity` is **not**
132
+ * derivable: the live method compares the lockfile against the workspace
133
+ * manifests discovery enumerates, and the double has no discovery to ask, so
134
+ * it dies unless stubbed.
135
+ *
136
+ * `refresh` defaults to `Effect.void` honestly: the live contract is "drop
137
+ * the memoized read so the next call re-reads", and this double memoizes
138
+ * nothing — every `read()` call re-invokes the override — so there is
139
+ * nothing to drop and the no-op is truthful, the same reasoning as
140
+ * `WorkspaceDiscovery.makeTest`'s `refresh`.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { Lockfile } from "@effected/lockfiles";
145
+ * import { LockfileReader } from "@effected/workspaces";
146
+ * import { Effect } from "effect";
147
+ *
148
+ * const double = LockfileReader.makeTest({
149
+ * read: () =>
150
+ * Effect.succeed(
151
+ * Lockfile.make({ format: "pnpm", lockfileVersion: "9.0", packages: [], workspaceDependencies: [] }),
152
+ * ),
153
+ * });
154
+ * // `resolvedVersion` now answers consistently from that lockfile.
155
+ * ```
156
+ */
157
+ static makeTest = (overrides = {}) => {
158
+ const read = overrides.read;
159
+ return {
160
+ read: () => unstubbed("read"),
161
+ resolvedVersion: read !== void 0 ? (packageName) => Effect.map(read(), (lockfile) => Option.fromUndefinedOr(lockfile.packagesNamed(packageName)[0])) : () => unstubbed("resolvedVersion"),
162
+ integrity: () => unstubbed("integrity"),
163
+ refresh: () => Effect.void,
164
+ ...overrides
165
+ };
166
+ };
167
+ /**
168
+ * The test layer: {@link LockfileReader.makeTest} behind `Layer.succeed`, so
169
+ * a suite provides only the methods it exercises.
170
+ *
171
+ * @remarks
172
+ * A parameterized layer factory mints a **fresh reference per call**, and
173
+ * layers memoize by reference — bind the result to a `const` and reuse it
174
+ * rather than calling `layerTest(...)` at each composition site.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * import { LockfileReader } from "@effected/workspaces";
179
+ *
180
+ * const TestLockfiles = LockfileReader.layerTest();
181
+ * // program.pipe(Effect.provide(TestLockfiles)) — dies loudly if touched.
182
+ * ```
183
+ */
184
+ static layerTest = (overrides = {}) => Layer.succeed(LockfileReader, LockfileReader.makeTest(overrides));
115
185
  };
116
186
 
117
187
  //#endregion
package/README.md CHANGED
@@ -134,7 +134,21 @@ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyErro
134
134
 
135
135
  ## The synchronous escape hatch
136
136
 
137
- 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 the module itself imports nothing platform-shaped, and Node's built-ins satisfy the operations one-liner each:
137
+ 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.
138
+
139
+ ```ts
140
+ import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
141
+ import { nodeSyncOps } from "@effected/workspaces/node-sync";
142
+
143
+ const root = findWorkspaceRootSync(process.cwd(), nodeSyncOps);
144
+ const packages = root === null ? [] : getWorkspacePackagesSync(root, nodeSyncOps);
145
+ // root: the workspace root path, or null when none is found above the cwd
146
+ // packages: the discovered workspace packages, empty when there is no root
147
+ ```
148
+
149
+ Both entry points take their path positionally, so the bag usually passes through verbatim; spread it to add `getWorkspacePackagesSync`'s traversal extras — `{ ...nodeSyncOps, maxDepth }`. The bindings are a separate subpath deliberately: the main entry imports nothing platform-shaped, and re-exporting them from it would drag `node:*` into every consumer, including the ones supplying their own operations. `nodePath` is the running platform's `node:path`, so on Windows the paths handed back are win32 paths.
150
+
151
+ Write the operations yourself when Node's built-ins are not the platform you mean — a Bun or Deno binding, a test fake, or `node:path/win32` to pin a dialect rather than follow the running platform. Each one is a one-liner:
138
152
 
139
153
  ```ts
140
154
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -157,7 +171,7 @@ const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
157
171
  // packages: the discovered workspace packages, empty when there is no root
158
172
  ```
159
173
 
160
- Windows correctness is the operations you pass `node:path` on Windows is already win32-appropriate. Both entry points drive one traversal state machine (the same dequeue order, depth rule, visit budget and `node_modules` prune), so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
174
+ Windows correctness is therefore the operations you pass, and nothing else. Both entry points drive one traversal state machine (the same dequeue order, depth rule, visit budget and `node_modules` prune), so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
161
175
 
162
176
  ## Error handling
163
177
 
@@ -220,6 +234,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
220
234
  - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
221
235
  - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). The default layer implements npm's semantics; swap the layer if yours differ.
222
236
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
237
+ - `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own.
223
238
 
224
239
  ## License
225
240
 
@@ -2,6 +2,7 @@ import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
2
  import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
3
3
  import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
4
4
  import { LockfileReader } from "./LockfileReader.js";
5
+ import { importerVersionsOf } from "./internal/importerVersions.js";
5
6
  import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
6
7
  import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, PartialReleaseAgeGate, ReleaseAgeGate } from "@effected/npm";
7
8
  import { Yaml } from "@effected/yaml";
@@ -282,6 +283,8 @@ const validatePnpmWorkspaceCatalogs = (document) => {
282
283
  catalogs: "catalogs"
283
284
  }));
284
285
  };
286
+ /** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
287
+ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceCatalogs.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
285
288
  /**
286
289
  * Assembles a workspace's catalogs, package-manager-aware.
287
290
  *
@@ -325,7 +328,15 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
325
328
  }));
326
329
  const assemble = Effect.gen(function* () {
327
330
  const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
328
- const fromLockfile = yield* lockfiles.read().pipe(Effect.map(CatalogSet.fromLockfile), Effect.catch((_failure) => Effect.succeed(CatalogSet.empty())));
331
+ const lockfileOutputs = yield* lockfiles.read().pipe(Effect.map((lockfile) => ({
332
+ catalogs: CatalogSet.fromLockfile(lockfile),
333
+ importerVersions: importerVersionsOf(lockfile)
334
+ })), Effect.catch((_failure) => Effect.succeed({
335
+ catalogs: CatalogSet.empty(),
336
+ importerVersions: {}
337
+ })));
338
+ const fromLockfile = lockfileOutputs.catalogs;
339
+ const importerVersions = lockfileOutputs.importerVersions;
329
340
  const workspaceYaml = path.join(root, "pnpm-workspace.yaml");
330
341
  const hasPnpmWorkspace = yield* probeExists(workspaceYaml);
331
342
  let inline;
@@ -353,7 +364,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
353
364
  const manifestPath = path.join(root, "package.json");
354
365
  if (!(yield* probeExists(manifestPath))) return {
355
366
  catalogs: fromLockfile,
356
- releaseAgeGate: ReleaseAgeGate.combine()
367
+ releaseAgeGate: ReleaseAgeGate.combine(),
368
+ importerVersions
357
369
  };
358
370
  const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
359
371
  source: "manifest",
@@ -372,7 +384,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
372
384
  }));
373
385
  return {
374
386
  catalogs: assembled,
375
- releaseAgeGate
387
+ releaseAgeGate,
388
+ importerVersions
376
389
  };
377
390
  });
378
391
  const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
@@ -386,6 +399,9 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
386
399
  }),
387
400
  releaseAgeGate: Effect.fn("WorkspaceCatalogs.releaseAgeGate")(function* () {
388
401
  return (yield* memo).releaseAgeGate;
402
+ }),
403
+ importerVersions: Effect.fn("WorkspaceCatalogs.importerVersions")(function* () {
404
+ return (yield* memo).importerVersions;
389
405
  })
390
406
  };
391
407
  });
@@ -412,6 +428,70 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
412
428
  */
413
429
  static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
414
430
  /**
431
+ * A test double satisfying the full {@link WorkspaceCatalogsShape} with no
432
+ * filesystem, lockfile read, or hook replay.
433
+ *
434
+ * @remarks
435
+ * There is **no honest default catalog set**: an empty `CatalogSet` that
436
+ * looks like a legitimate answer is the "every dependency looks newly added"
437
+ * failure class the live assembler hard-fails to prevent, so every method
438
+ * **dies** with an instructive defect until stubbed — a test-wiring mistake
439
+ * fails loudly as a defect rather than succeeding with a lie or failing with
440
+ * a dishonest typed error.
441
+ *
442
+ * The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
443
+ * derived-from-the-primary rule: when a `set` override is supplied,
444
+ * `resolveSpecifier` answers from that `CatalogSet`'s own
445
+ * {@link CatalogSet.resolveSpecifier} — exactly what the live service runs
446
+ * over its assembled set — so the two stay consistent by construction.
447
+ * `releaseAgeGate` and `importerVersions` are **not** derivable from a
448
+ * catalog set (the gate comes from release-age keys and hook contributions,
449
+ * the importer index from the lockfile's importer blocks — neither is in a
450
+ * `CatalogSet`) and always die unless stubbed.
451
+ *
452
+ * @example
453
+ * ```ts
454
+ * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
455
+ * import { Effect } from "effect";
456
+ *
457
+ * const double = WorkspaceCatalogs.makeTest({
458
+ * set: () => Effect.succeed(CatalogSet.fromCatalogs({ default: { effect: "4.0.0" } })),
459
+ * });
460
+ * // `resolveSpecifier` now answers consistently from that set.
461
+ * ```
462
+ */
463
+ static makeTest = (overrides = {}) => {
464
+ const set = overrides.set;
465
+ return {
466
+ set: () => unstubbed("set"),
467
+ resolveSpecifier: set !== void 0 ? (dependency, specifier) => Effect.map(set(), (catalogs) => catalogs.resolveSpecifier(dependency, specifier)) : () => unstubbed("resolveSpecifier"),
468
+ releaseAgeGate: () => unstubbed("releaseAgeGate"),
469
+ importerVersions: () => unstubbed("importerVersions"),
470
+ ...overrides
471
+ };
472
+ };
473
+ /**
474
+ * The test layer: {@link WorkspaceCatalogs.makeTest} behind `Layer.succeed`,
475
+ * so a suite provides only the methods it exercises.
476
+ *
477
+ * @remarks
478
+ * A parameterized layer factory mints a **fresh reference per call**, and
479
+ * layers memoize by reference — bind the result to a `const` and reuse it
480
+ * rather than calling `layerTest(...)` at each composition site.
481
+ *
482
+ * @example
483
+ * ```ts
484
+ * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
485
+ * import { Effect } from "effect";
486
+ *
487
+ * const TestCatalogs = WorkspaceCatalogs.layerTest({
488
+ * set: () => Effect.succeed(CatalogSet.empty()),
489
+ * });
490
+ * // program.pipe(Effect.provide(TestCatalogs))
491
+ * ```
492
+ */
493
+ static layerTest = (overrides = {}) => Layer.succeed(WorkspaceCatalogs, WorkspaceCatalogs.makeTest(overrides));
494
+ /**
415
495
  * The real implementation of `@effected/npm`'s `CatalogResolver` contract —
416
496
  * the one `@effected/package-json` declares but cannot fill.
417
497
  *
Binary file
@@ -1,3 +1,4 @@
1
+ import { unanimousVersionOf } from "./internal/importerVersions.js";
1
2
  import { CatalogSet } from "./WorkspaceCatalogs.js";
2
3
  import { Effect, Exit, Layer, Option, Schema } from "effect";
3
4
  import { CatalogResolver, DependencySpecifier, WorkspaceResolver } from "@effected/npm";
@@ -86,7 +87,19 @@ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot"
86
87
  /** Every workspace package captured at this moment. */
87
88
  packages: Schema.Array(PackageStateSnapshot),
88
89
  /** The catalog set assembled at this moment. */
89
- catalogs: CatalogSet
90
+ catalogs: CatalogSet,
91
+ /**
92
+ * Each importer's dependency-name → resolved-version map, as the manager's
93
+ * lockfile recorded it at this moment.
94
+ *
95
+ * @remarks
96
+ * Defaults to `{}`, so a `WorkspaceStateSnapshot` serialized before this field
97
+ * existed still decodes — and an empty index simply makes the `catalog:`
98
+ * fallback in {@link WorkspaceStateSnapshot.resolve} inert, which is exactly
99
+ * the behavior those older values were captured under. Only pnpm records
100
+ * importer versions; bun and npm yield an empty index.
101
+ */
102
+ importerVersions: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String)))
90
103
  }) {
91
104
  #versionIndex;
92
105
  #packageIndex;
@@ -121,16 +134,62 @@ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot"
121
134
  * unparseable string — is `Option.none()`, because there is no indirection to
122
135
  * resolve. Total.
123
136
  *
137
+ * A `catalog:` specifier the catalog set cannot resolve falls back to this
138
+ * snapshot's `importerVersions` — but only to a version
139
+ * **every** importer recording that dependency agrees on. A catalog injected
140
+ * by a config-dependency pnpmfile hook appears in no committed catalog source,
141
+ * so without this fallback both sides of a before/after diff resolve it to the
142
+ * same raw string and a real version movement produces no row. When importers
143
+ * disagree there is no single correct answer, so this stays `Option.none()`
144
+ * rather than inventing one; {@link WorkspaceStateSnapshot.resolveIn} answers
145
+ * precisely for callers that know which importer is asking.
146
+ *
124
147
  * @param dependency - The dependency's package name (what `workspace:` /
125
148
  * `catalog:` resolve for).
126
149
  * @param specifier - The raw specifier string.
127
150
  */
128
151
  resolve(dependency, specifier) {
152
+ return this.#resolveWith(dependency, specifier, () => Option.fromUndefinedOr(unanimousVersionOf(this.importerVersions ?? {}, dependency)));
153
+ }
154
+ /**
155
+ * The concrete range or version a specifier resolved to AS OF this snapshot,
156
+ * scoped to the importer that declared it.
157
+ *
158
+ * @remarks
159
+ * Identical to {@link WorkspaceStateSnapshot.resolve} except in how an
160
+ * unresolvable `catalog:` specifier falls back: this consults **only**
161
+ * `importerPath`'s own recorded versions, so a monorepo whose packages hold
162
+ * different versions of one dependency still gets an exact answer where
163
+ * `resolve` must abstain. Prefer this whenever the caller knows the importer —
164
+ * a consumer iterating `packages` has `relativePath` in hand, which is the
165
+ * importer key (`"."` for the root package).
166
+ *
167
+ * An unknown `importerPath`, or one recording nothing for `dependency`, is
168
+ * `Option.none()`. Total.
169
+ *
170
+ * @param importerPath - The importer's path relative to the workspace root,
171
+ * `"."` for the root package — `PackageStateSnapshot.relativePath`.
172
+ * @param dependency - The dependency's package name.
173
+ * @param specifier - The raw specifier string.
174
+ */
175
+ resolveIn(importerPath, dependency, specifier) {
176
+ return this.#resolveWith(dependency, specifier, () => Option.fromUndefinedOr(this.importerVersions?.[importerPath]?.[dependency]));
177
+ }
178
+ /**
179
+ * The shared resolution path: classify, answer from the captured state, and
180
+ * consult `onUnresolvedCatalog` only for a `catalog:` specifier the catalog set
181
+ * could not answer. A plain range is already its own answer and must keep
182
+ * resolving to `Option.none()` so the caller falls back to the raw string.
183
+ */
184
+ #resolveWith(dependency, specifier, onUnresolvedCatalog) {
129
185
  const exit = Schema.decodeUnknownExit(DependencySpecifier.FromString)(specifier);
130
186
  if (!Exit.isSuccess(exit)) return Option.none();
131
187
  const classified = exit.value;
132
188
  switch (classified._tag) {
133
- case "catalog": return this.catalogs.rangeOf(dependency, classified.name);
189
+ case "catalog": {
190
+ const fromCatalogs = this.catalogs.rangeOf(dependency, classified.name);
191
+ return Option.isSome(fromCatalogs) ? fromCatalogs : onUnresolvedCatalog();
192
+ }
134
193
  case "workspace": return Option.fromUndefinedOr(this.#versions().get(dependency));
135
194
  default: return Option.none();
136
195
  }
package/index.d.ts CHANGED
@@ -1212,6 +1212,65 @@ declare class LockfileReader extends LockfileReader_base {
1212
1212
  * `const` and reuse it.
1213
1213
  */
1214
1214
  static readonly layer: (options?: LockfileReaderOptions) => Layer.Layer<LockfileReader, never, WorkspaceRoot | PackageManagerDetector | WorkspaceDiscovery | FileSystem.FileSystem | Path.Path>;
1215
+ /**
1216
+ * A test double satisfying the full {@link LockfileReaderShape} with no
1217
+ * filesystem, root walk, or package-manager detection.
1218
+ *
1219
+ * @remarks
1220
+ * There is **no honest default lockfile**: an empty one that looks like a
1221
+ * legitimate answer is indistinguishable from "this workspace resolves
1222
+ * nothing" — the silent-empty failure class this package documents on the
1223
+ * live paths — so `read` **dies** with an instructive defect until stubbed.
1224
+ *
1225
+ * The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
1226
+ * derived-from-the-primary rule: when a `read` override is supplied,
1227
+ * `resolvedVersion` answers as the live service does — the **first** entry
1228
+ * of `lockfile.packagesNamed(name)` in lockfile order, `Option.none()` on a
1229
+ * miss — so the two stay consistent by construction. `integrity` is **not**
1230
+ * derivable: the live method compares the lockfile against the workspace
1231
+ * manifests discovery enumerates, and the double has no discovery to ask, so
1232
+ * it dies unless stubbed.
1233
+ *
1234
+ * `refresh` defaults to `Effect.void` honestly: the live contract is "drop
1235
+ * the memoized read so the next call re-reads", and this double memoizes
1236
+ * nothing — every `read()` call re-invokes the override — so there is
1237
+ * nothing to drop and the no-op is truthful, the same reasoning as
1238
+ * `WorkspaceDiscovery.makeTest`'s `refresh`.
1239
+ *
1240
+ * @example
1241
+ * ```ts
1242
+ * import { Lockfile } from "@effected/lockfiles";
1243
+ * import { LockfileReader } from "@effected/workspaces";
1244
+ * import { Effect } from "effect";
1245
+ *
1246
+ * const double = LockfileReader.makeTest({
1247
+ * read: () =>
1248
+ * Effect.succeed(
1249
+ * Lockfile.make({ format: "pnpm", lockfileVersion: "9.0", packages: [], workspaceDependencies: [] }),
1250
+ * ),
1251
+ * });
1252
+ * // `resolvedVersion` now answers consistently from that lockfile.
1253
+ * ```
1254
+ */
1255
+ static readonly makeTest: (overrides?: Partial<LockfileReaderShape>) => LockfileReaderShape;
1256
+ /**
1257
+ * The test layer: {@link LockfileReader.makeTest} behind `Layer.succeed`, so
1258
+ * a suite provides only the methods it exercises.
1259
+ *
1260
+ * @remarks
1261
+ * A parameterized layer factory mints a **fresh reference per call**, and
1262
+ * layers memoize by reference — bind the result to a `const` and reuse it
1263
+ * rather than calling `layerTest(...)` at each composition site.
1264
+ *
1265
+ * @example
1266
+ * ```ts
1267
+ * import { LockfileReader } from "@effected/workspaces";
1268
+ *
1269
+ * const TestLockfiles = LockfileReader.layerTest();
1270
+ * // program.pipe(Effect.provide(TestLockfiles)) — dies loudly if touched.
1271
+ * ```
1272
+ */
1273
+ static readonly layerTest: (overrides?: Partial<LockfileReaderShape>) => Layer.Layer<LockfileReader>;
1215
1274
  }
1216
1275
  //#endregion
1217
1276
  //#region src/Publishability.d.ts
@@ -1323,6 +1382,26 @@ declare class PublishabilityDetector extends PublishabilityDetector_base {
1323
1382
  }
1324
1383
  //#endregion
1325
1384
  //#region src/WorkspaceCatalogs.d.ts
1385
+ /**
1386
+ * Each importer's dependency-name → resolved-version map, keyed by importer path
1387
+ * (`"."` for the root package — the same keys `WorkspaceDiscovery.importerMap()`
1388
+ * uses, and the same value `PackageStateSnapshot.relativePath` carries).
1389
+ *
1390
+ * @remarks
1391
+ * What a manager's lockfile records as actually installed, as opposed to what a
1392
+ * manifest declares. It answers a `catalog:` specifier no committed catalog
1393
+ * source declares — the shape a pnpm config-dependency pnpmfile hook produces —
1394
+ * which would otherwise resolve to nothing on both sides of a diff and hide a
1395
+ * real version movement.
1396
+ *
1397
+ * Versions are normalized: pnpm's peer-disambiguation suffix
1398
+ * (`1.2.3(effect@4.0.0)`) is stripped, and `link:` / `file:` entries are omitted
1399
+ * because a filesystem edge is not a version. Only pnpm records importer
1400
+ * versions; bun and npm yield an empty index.
1401
+ *
1402
+ * @public
1403
+ */
1404
+ type ImporterVersions = Readonly<Record<string, Readonly<Record<string, string>>>>;
1326
1405
  declare const CatalogSet_base: Schema.Class<CatalogSet, Schema.Struct<{
1327
1406
  /** Catalog name → dependency name → version range. */
1328
1407
  readonly entries: Schema.$Record<Schema.String, Schema.$Record<Schema.String, Schema.String>>;
@@ -1455,6 +1534,19 @@ interface WorkspaceCatalogsShape {
1455
1534
  * workspace) has no release-age keys, so the gate is the inert zero gate.
1456
1535
  */
1457
1536
  readonly releaseAgeGate: () => Effect.Effect<ReleaseAgeGate, CatalogAssemblyFailure>;
1537
+ /**
1538
+ * Each importer's dependency-name → resolved-version map, as the manager's
1539
+ * lockfile records it. Read from the same single lockfile read as `set`, and
1540
+ * memoized with it.
1541
+ *
1542
+ * @remarks
1543
+ * Feeds `WorkspaceStateSnapshot`'s fallback for a `catalog:` specifier no
1544
+ * committed catalog source declares — the shape a config-dependency pnpmfile
1545
+ * hook produces. Only pnpm records importer versions; a bun or npm workspace
1546
+ * yields an empty index, and an absent or unreadable lockfile contributes
1547
+ * nothing rather than failing.
1548
+ */
1549
+ readonly importerVersions: () => Effect.Effect<ImporterVersions, CatalogAssemblyFailure>;
1458
1550
  }
1459
1551
  /**
1460
1552
  * Options for the {@link WorkspaceCatalogs} layer.
@@ -1520,6 +1612,61 @@ declare class WorkspaceCatalogs extends WorkspaceCatalogs_base {
1520
1612
  * Parameterized, so bind it to a `const` and reuse it.
1521
1613
  */
1522
1614
  static readonly layerWithConfigDependencies: (options?: WorkspaceCatalogsOptions) => Layer.Layer<WorkspaceCatalogs, never, WorkspaceRoot | LockfileReader | FileSystem.FileSystem | Path.Path>;
1615
+ /**
1616
+ * A test double satisfying the full {@link WorkspaceCatalogsShape} with no
1617
+ * filesystem, lockfile read, or hook replay.
1618
+ *
1619
+ * @remarks
1620
+ * There is **no honest default catalog set**: an empty `CatalogSet` that
1621
+ * looks like a legitimate answer is the "every dependency looks newly added"
1622
+ * failure class the live assembler hard-fails to prevent, so every method
1623
+ * **dies** with an instructive defect until stubbed — a test-wiring mistake
1624
+ * fails loudly as a defect rather than succeeding with a lie or failing with
1625
+ * a dishonest typed error.
1626
+ *
1627
+ * The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
1628
+ * derived-from-the-primary rule: when a `set` override is supplied,
1629
+ * `resolveSpecifier` answers from that `CatalogSet`'s own
1630
+ * {@link CatalogSet.resolveSpecifier} — exactly what the live service runs
1631
+ * over its assembled set — so the two stay consistent by construction.
1632
+ * `releaseAgeGate` and `importerVersions` are **not** derivable from a
1633
+ * catalog set (the gate comes from release-age keys and hook contributions,
1634
+ * the importer index from the lockfile's importer blocks — neither is in a
1635
+ * `CatalogSet`) and always die unless stubbed.
1636
+ *
1637
+ * @example
1638
+ * ```ts
1639
+ * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
1640
+ * import { Effect } from "effect";
1641
+ *
1642
+ * const double = WorkspaceCatalogs.makeTest({
1643
+ * set: () => Effect.succeed(CatalogSet.fromCatalogs({ default: { effect: "4.0.0" } })),
1644
+ * });
1645
+ * // `resolveSpecifier` now answers consistently from that set.
1646
+ * ```
1647
+ */
1648
+ static readonly makeTest: (overrides?: Partial<WorkspaceCatalogsShape>) => WorkspaceCatalogsShape;
1649
+ /**
1650
+ * The test layer: {@link WorkspaceCatalogs.makeTest} behind `Layer.succeed`,
1651
+ * so a suite provides only the methods it exercises.
1652
+ *
1653
+ * @remarks
1654
+ * A parameterized layer factory mints a **fresh reference per call**, and
1655
+ * layers memoize by reference — bind the result to a `const` and reuse it
1656
+ * rather than calling `layerTest(...)` at each composition site.
1657
+ *
1658
+ * @example
1659
+ * ```ts
1660
+ * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
1661
+ * import { Effect } from "effect";
1662
+ *
1663
+ * const TestCatalogs = WorkspaceCatalogs.layerTest({
1664
+ * set: () => Effect.succeed(CatalogSet.empty()),
1665
+ * });
1666
+ * // program.pipe(Effect.provide(TestCatalogs))
1667
+ * ```
1668
+ */
1669
+ static readonly layerTest: (overrides?: Partial<WorkspaceCatalogsShape>) => Layer.Layer<WorkspaceCatalogs>;
1523
1670
  /**
1524
1671
  * The real implementation of `@effected/npm`'s `CatalogResolver` contract —
1525
1672
  * the one `@effected/package-json` declares but cannot fill.
@@ -1583,6 +1730,18 @@ declare const WorkspaceStateSnapshot_base: Schema.Class<WorkspaceStateSnapshot,
1583
1730
  readonly packages: Schema.$Array<typeof PackageStateSnapshot>;
1584
1731
  /** The catalog set assembled at this moment. */
1585
1732
  readonly catalogs: typeof CatalogSet;
1733
+ /**
1734
+ * Each importer's dependency-name → resolved-version map, as the manager's
1735
+ * lockfile recorded it at this moment.
1736
+ *
1737
+ * @remarks
1738
+ * Defaults to `{}`, so a `WorkspaceStateSnapshot` serialized before this field
1739
+ * existed still decodes — and an empty index simply makes the `catalog:`
1740
+ * fallback in {@link WorkspaceStateSnapshot.resolve} inert, which is exactly
1741
+ * the behavior those older values were captured under. Only pnpm records
1742
+ * importer versions; bun and npm yield an empty index.
1743
+ */
1744
+ readonly importerVersions: Schema.optionalKey<Schema.$Record<Schema.String, Schema.$Record<Schema.String, Schema.String>>>;
1586
1745
  }>, {}>;
1587
1746
  /**
1588
1747
  * The state of a whole workspace at one moment — its packages and its assembled
@@ -1632,11 +1791,43 @@ declare class WorkspaceStateSnapshot extends WorkspaceStateSnapshot_base {
1632
1791
  * unparseable string — is `Option.none()`, because there is no indirection to
1633
1792
  * resolve. Total.
1634
1793
  *
1794
+ * A `catalog:` specifier the catalog set cannot resolve falls back to this
1795
+ * snapshot's `importerVersions` — but only to a version
1796
+ * **every** importer recording that dependency agrees on. A catalog injected
1797
+ * by a config-dependency pnpmfile hook appears in no committed catalog source,
1798
+ * so without this fallback both sides of a before/after diff resolve it to the
1799
+ * same raw string and a real version movement produces no row. When importers
1800
+ * disagree there is no single correct answer, so this stays `Option.none()`
1801
+ * rather than inventing one; {@link WorkspaceStateSnapshot.resolveIn} answers
1802
+ * precisely for callers that know which importer is asking.
1803
+ *
1635
1804
  * @param dependency - The dependency's package name (what `workspace:` /
1636
1805
  * `catalog:` resolve for).
1637
1806
  * @param specifier - The raw specifier string.
1638
1807
  */
1639
1808
  resolve(dependency: string, specifier: string): Option.Option<string>;
1809
+ /**
1810
+ * The concrete range or version a specifier resolved to AS OF this snapshot,
1811
+ * scoped to the importer that declared it.
1812
+ *
1813
+ * @remarks
1814
+ * Identical to {@link WorkspaceStateSnapshot.resolve} except in how an
1815
+ * unresolvable `catalog:` specifier falls back: this consults **only**
1816
+ * `importerPath`'s own recorded versions, so a monorepo whose packages hold
1817
+ * different versions of one dependency still gets an exact answer where
1818
+ * `resolve` must abstain. Prefer this whenever the caller knows the importer —
1819
+ * a consumer iterating `packages` has `relativePath` in hand, which is the
1820
+ * importer key (`"."` for the root package).
1821
+ *
1822
+ * An unknown `importerPath`, or one recording nothing for `dependency`, is
1823
+ * `Option.none()`. Total.
1824
+ *
1825
+ * @param importerPath - The importer's path relative to the workspace root,
1826
+ * `"."` for the root package — `PackageStateSnapshot.relativePath`.
1827
+ * @param dependency - The dependency's package name.
1828
+ * @param specifier - The raw specifier string.
1829
+ */
1830
+ resolveIn(importerPath: string, dependency: string, specifier: string): Option.Option<string>;
1640
1831
  /**
1641
1832
  * A `CatalogResolver` layer implementing `@effected/npm`'s contract against
1642
1833
  * THIS snapshot's catalog set — so code written to the contract resolves
@@ -1751,6 +1942,62 @@ declare class WorkspaceSnapshots extends WorkspaceSnapshots_base {
1751
1942
  * `const` and reuse it, or layer memoization does not apply.
1752
1943
  */
1753
1944
  static readonly layer: (options?: WorkspaceSnapshotsOptions) => Layer.Layer<WorkspaceSnapshots, never, Git | WorkspaceRoot | WorkspaceDiscovery | WorkspaceCatalogs>;
1945
+ /**
1946
+ * A test double satisfying the full {@link WorkspaceSnapshotsShape} with no
1947
+ * git, filesystem, discovery, or catalog assembly.
1948
+ *
1949
+ * @remarks
1950
+ * There are **no honest defaults and no derivations** here: `at(ref)` and
1951
+ * `worktree()` are two independent reads of two different sources (a git ref
1952
+ * vs. the live tree), so neither can be honestly derived from the other, and
1953
+ * a fabricated empty {@link WorkspaceStateSnapshot} on either side of a
1954
+ * before/after diff reads as every dependency newly added or removed — the
1955
+ * exact silent-empty failure class this package documents on the live paths.
1956
+ * Both methods therefore **die** with an instructive defect until stubbed; a
1957
+ * test-wiring mistake fails loudly as a defect rather than succeeding with a
1958
+ * lie.
1959
+ *
1960
+ * @example
1961
+ * ```ts
1962
+ * import { CatalogSet, WorkspaceSnapshots, WorkspaceStateSnapshot } from "@effected/workspaces";
1963
+ * import { Effect } from "effect";
1964
+ *
1965
+ * const empty = WorkspaceStateSnapshot.make({
1966
+ * packages: [],
1967
+ * catalogs: CatalogSet.empty(),
1968
+ * importerVersions: {},
1969
+ * });
1970
+ * const double = WorkspaceSnapshots.makeTest({
1971
+ * at: () => Effect.succeed(empty),
1972
+ * worktree: () => Effect.succeed(empty),
1973
+ * });
1974
+ * ```
1975
+ */
1976
+ static readonly makeTest: (overrides?: Partial<WorkspaceSnapshotsShape>) => WorkspaceSnapshotsShape;
1977
+ /**
1978
+ * The test layer: {@link WorkspaceSnapshots.makeTest} behind `Layer.succeed`,
1979
+ * so a suite provides only the methods it exercises.
1980
+ *
1981
+ * @remarks
1982
+ * A parameterized layer factory mints a **fresh reference per call**, and
1983
+ * layers memoize by reference — bind the result to a `const` and reuse it
1984
+ * rather than calling `layerTest(...)` at each composition site.
1985
+ *
1986
+ * @example
1987
+ * ```ts
1988
+ * import { CatalogSet, WorkspaceSnapshots, WorkspaceStateSnapshot } from "@effected/workspaces";
1989
+ * import { Effect } from "effect";
1990
+ *
1991
+ * const TestSnapshots = WorkspaceSnapshots.layerTest({
1992
+ * worktree: () =>
1993
+ * Effect.succeed(
1994
+ * WorkspaceStateSnapshot.make({ packages: [], catalogs: CatalogSet.empty(), importerVersions: {} }),
1995
+ * ),
1996
+ * });
1997
+ * // program.pipe(Effect.provide(TestSnapshots))
1998
+ * ```
1999
+ */
2000
+ static readonly layerTest: (overrides?: Partial<WorkspaceSnapshotsShape>) => Layer.Layer<WorkspaceSnapshots>;
1754
2001
  }
1755
2002
  //#endregion
1756
2003
  //#region src/Workspaces.d.ts
@@ -1979,5 +2226,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
1979
2226
  */
1980
2227
  declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
1981
2228
  //#endregion
1982
- export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, type SyncFileSystem, type SyncPath, 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, findWorkspaceRootSync, getWorkspacePackagesSync };
2229
+ export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, 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, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, type SyncFileSystem, type SyncPath, 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, findWorkspaceRootSync, getWorkspacePackagesSync };
1983
2230
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,88 @@
1
+ //#region src/internal/importerVersions.ts
2
+ /**
3
+ * Strip pnpm's peer-disambiguation suffix from a recorded importer version.
4
+ *
5
+ * pnpm records an importer version as the resolved version followed by the peer
6
+ * context it was resolved under — `4.0.0-beta.101(effect@4.0.0-beta.101)(ioredis@5.11.1(supports-color@8.1.1))`.
7
+ * `@effected/lockfiles` stores that string verbatim (its `packages:` key parser
8
+ * strips the suffix; the importer entries deliberately keep it), so the raw
9
+ * value must never reach a consumer's dependency table — it would render the
10
+ * whole parenthesized chain as the "version" a dependency moved to.
11
+ *
12
+ * Everything from the first `(` is dropped; a version with no suffix passes
13
+ * through untouched.
14
+ */
15
+ const stripPeerSuffix = (version) => {
16
+ const paren = version.indexOf("(");
17
+ return paren === -1 ? version : version.slice(0, paren);
18
+ };
19
+ /**
20
+ * Whether a recorded importer version is a concrete version usable as a
21
+ * resolution answer.
22
+ *
23
+ * A `link:` / `file:` entry records a filesystem edge rather than a version, and
24
+ * an empty string is pnpm's "specifier only, nothing installed" marker. Both
25
+ * would be worse than no answer at all: the fallback exists to report a concrete
26
+ * version movement, and reporting `link:../foo` as a version is noise a
27
+ * changeset would carry into release notes.
28
+ */
29
+ const isConcreteVersion = (version) => version.length > 0 && !version.startsWith("link:") && !version.startsWith("file:");
30
+ /**
31
+ * Build the importer-path → dependency-name → version index a snapshot uses to
32
+ * answer `catalog:` specifiers its catalog set cannot resolve.
33
+ *
34
+ * @remarks
35
+ * Keyed by **name only** within each importer, deliberately across every
36
+ * dependency field. The bug this fixes is a peer declared `catalog:effect:peers`
37
+ * whose importer records no `peerDependencies` entry at all — pnpm writes peer
38
+ * declarations into the importer block only when they are also installed, so the
39
+ * concrete version lives on the package's `devDependencies` row for the same
40
+ * name. Joining by name across fields is what finds it; joining by field, or by
41
+ * matching the recorded specifier, would not.
42
+ *
43
+ * Only pnpm populates importer versions; bun and npm record resolved versions on
44
+ * their package entries instead, so those formats yield an empty index and the
45
+ * fallback is inert for them.
46
+ */
47
+ const importerVersionsOf = (lockfile) => {
48
+ const index = Object.create(null);
49
+ for (const importer of lockfile.importers) {
50
+ const versions = Object.create(null);
51
+ for (const dependency of importer.dependencies) {
52
+ const recorded = dependency.version;
53
+ if (recorded === void 0) continue;
54
+ const version = stripPeerSuffix(recorded);
55
+ if (!isConcreteVersion(version)) continue;
56
+ versions[dependency.name] ??= version;
57
+ }
58
+ index[importer.path] = versions;
59
+ }
60
+ return index;
61
+ };
62
+ /**
63
+ * The version every importer agrees `dependency` resolved to, or `undefined`
64
+ * when they disagree or none record it.
65
+ *
66
+ * @remarks
67
+ * The unambiguity rule is what makes a workspace-wide answer safe from a
68
+ * snapshot method that receives no importer context. Two packages in one
69
+ * monorepo may legitimately hold different versions of the same dependency; in
70
+ * that case there is no single correct answer, so this yields nothing and the
71
+ * caller keeps today's behavior (no row) rather than inventing a wrong one.
72
+ */
73
+ const unanimousVersionOf = (index, dependency) => {
74
+ let agreed;
75
+ for (const versions of Object.values(index)) {
76
+ const version = versions[dependency];
77
+ if (version === void 0) continue;
78
+ if (agreed === void 0) {
79
+ agreed = version;
80
+ continue;
81
+ }
82
+ if (agreed !== version) return void 0;
83
+ }
84
+ return agreed;
85
+ };
86
+
87
+ //#endregion
88
+ export { importerVersionsOf, unanimousVersionOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.6.2",
3
+ "version": "0.8.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": [
@@ -46,14 +46,14 @@
46
46
  "./package.json": "./package.json"
47
47
  },
48
48
  "dependencies": {
49
- "@effected/git": "~0.4.2",
49
+ "@effected/git": "~0.5.0",
50
50
  "@effected/glob": "~0.2.1",
51
- "@effected/lockfiles": "~0.1.10",
52
- "@effected/npm": "~0.3.1",
53
- "@effected/package-json": "~0.5.1",
51
+ "@effected/lockfiles": "~0.2.0",
52
+ "@effected/npm": "~0.4.0",
53
+ "@effected/package-json": "~0.5.2",
54
54
  "@effected/semver": "~0.2.1",
55
55
  "@effected/walker": "~0.3.2",
56
- "@effected/yaml": "~0.5.1",
56
+ "@effected/yaml": "~0.6.0",
57
57
  "@pnpm/catalogs.config": "^1100.0.0",
58
58
  "@pnpm/catalogs.protocol-parser": "^1100.0.0",
59
59
  "@pnpm/catalogs.resolver": "^1100.0.0",