@effected/workspaces 0.23.0 → 0.24.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.
@@ -1,8 +1,9 @@
1
1
  import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
2
  import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
3
- import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
3
+ import { ConfigDependencyHooks, NoPeerDependencyRules } from "./ConfigDependencyHooks.js";
4
4
  import { LockfileReader } from "./LockfileReader.js";
5
5
  import { importerVersionsOf } from "./internal/importerVersions.js";
6
+ import { configDependenciesOf, inlinePeerDependencyRules } from "./internal/workspaceYaml.js";
6
7
  import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
7
8
  import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, PartialReleaseAgeGate, ReleaseAgeGate } from "@effected/npm";
8
9
  import { Yaml } from "@effected/yaml";
@@ -173,13 +174,6 @@ const catalogBlocksOf = (document) => {
173
174
  catalogs: raw.catalogs
174
175
  };
175
176
  };
176
- /** The `configDependencies` map (name → version+integrity) of a parsed pnpm-workspace document. */
177
- const configDependenciesOf = (document) => {
178
- if (!isObject(document) || !isObject(document.configDependencies)) return {};
179
- const out = {};
180
- for (const [name, spec] of Object.entries(document.configDependencies)) if (typeof spec === "string") out[name] = spec;
181
- return out;
182
- };
183
177
  /**
184
178
  * The inline release-age gate contribution of a parsed `pnpm-workspace.yaml`
185
179
  * document — pnpm's top-level `minimumReleaseAge` (minutes) and
@@ -211,41 +205,6 @@ const inlineReleaseAge = (document) => {
211
205
  cause
212
206
  })));
213
207
  };
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
- };
249
208
  /** A hard-fail catalog-assembly failure naming the malformed part of a `workspaces` field. */
250
209
  const malformed = (source, path, detail) => new CatalogAssemblyError({
251
210
  source,
@@ -321,6 +280,23 @@ const validatePnpmWorkspaceCatalogs = (document) => {
321
280
  /** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
322
281
  const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceCatalogs.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
323
282
  /**
283
+ * Replay a parsed `pnpm-workspace.yaml` document's `configDependencies`
284
+ * hooks over its inline catalogs, seeded by its inline `peerDependencyRules`
285
+ * — the ONE call into `hooks.inject` that both the live assembler and the
286
+ * at-ref reader (`WorkspaceSnapshots`) make, so the two sides of a diff
287
+ * cannot disagree about what a document declares or how it is seeded.
288
+ *
289
+ * @remarks
290
+ * Rules are SEEDED, not merged afterwards: the workspace file's block goes
291
+ * into the threaded config so the hooks merge onto it, exactly as pnpm seeds
292
+ * its own config and takes back what the hooks return. Not part of the public
293
+ * surface; `WorkspaceSnapshots` imports it directly.
294
+ */
295
+ const injectFromDocument = (hooks, root, document, inline) => Effect.map(hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document)), (injection) => ({
296
+ injected: CatalogSet.fromCatalogs(injection.catalogs),
297
+ injection
298
+ }));
299
+ /**
324
300
  * Assembles a workspace's catalogs, package-manager-aware.
325
301
  *
326
302
  * @remarks
@@ -378,7 +354,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
378
354
  let injected;
379
355
  let inlineGate = {};
380
356
  let hookGate = {};
381
- let peerDependencyRules = EMPTY_PEER_RULES;
357
+ let peerDependencyRules = NoPeerDependencyRules;
358
+ let hookReplays = {};
382
359
  if (hasPnpmWorkspace) {
383
360
  const text = yield* fs.readFileString(workspaceYaml).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
384
361
  source: "manifest",
@@ -393,17 +370,19 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
393
370
  yield* validatePnpmWorkspaceCatalogs(document);
394
371
  inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
395
372
  inlineGate = yield* inlineReleaseAge(document);
396
- const injection = yield* hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document));
397
- injected = CatalogSet.fromCatalogs(injection.catalogs);
398
- hookGate = injection.releaseAge;
399
- peerDependencyRules = injection.peerDependencyRules;
373
+ const replayed = yield* injectFromDocument(hooks, root, document, inline);
374
+ injected = replayed.injected;
375
+ hookGate = replayed.injection.releaseAge;
376
+ peerDependencyRules = replayed.injection.peerDependencyRules;
377
+ hookReplays = replayed.injection.replays;
400
378
  } else {
401
379
  const manifestPath = path.join(root, "package.json");
402
380
  if (!(yield* probeExists(manifestPath))) return {
403
381
  catalogs: fromLockfile,
404
382
  releaseAgeGate: ReleaseAgeGate.combine(),
405
383
  importerVersions,
406
- peerDependencyRules: EMPTY_PEER_RULES
384
+ peerDependencyRules: NoPeerDependencyRules,
385
+ hookReplays: {}
407
386
  };
408
387
  const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
409
388
  source: "manifest",
@@ -424,7 +403,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
424
403
  catalogs: assembled,
425
404
  releaseAgeGate,
426
405
  importerVersions,
427
- peerDependencyRules
406
+ peerDependencyRules,
407
+ hookReplays
428
408
  };
429
409
  });
430
410
  const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
@@ -445,10 +425,33 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
445
425
  importerVersions: Effect.fn("WorkspaceCatalogs.importerVersions")(function* () {
446
426
  return (yield* memo).importerVersions;
447
427
  }),
428
+ hookReplays: Effect.fn("WorkspaceCatalogs.hookReplays")(function* () {
429
+ return (yield* memo).hookReplays;
430
+ }),
448
431
  refresh: () => invalidate
449
432
  };
450
433
  });
451
434
  /**
435
+ * The service over an explicit {@link ConfigDependencyHooks} layer — the
436
+ * one builder every `layer*` static and the `Workspaces` composites
437
+ * delegate to, so the hooks layer is provided to {@link WorkspaceCatalogs.make}
438
+ * in exactly one place.
439
+ *
440
+ * @remarks
441
+ * Reach for it when the hooks layer is chosen elsewhere — a composite that
442
+ * must hand ONE hooks reference to both this service and
443
+ * `WorkspaceSnapshots` (layers memoize by reference, so two references
444
+ * would replay twice and could run two policies), or a test wiring
445
+ * {@link ConfigDependencyHooks.layerFrom}. The three `layer*` statics are
446
+ * this with the hooks layer fixed. `R` is the hooks layer's own
447
+ * requirement (the subprocess variant's `ChildProcessSpawner`), threaded
448
+ * through. Parameterized, so bind it to a `const` and reuse it.
449
+ *
450
+ * @param hooks - The {@link ConfigDependencyHooks} layer to replay through.
451
+ * @param options - The same options the other layer statics take.
452
+ */
453
+ static layerWithHooks = (hooks, options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(hooks));
454
+ /**
452
455
  * The live layer — the default, which **never executes config-dependency
453
456
  * code**: it wires {@link ConfigDependencyHooks.layerNoop}, whose hooks return
454
457
  * the inline-catalog seed untouched.
@@ -457,7 +460,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
457
460
  * Parameterized, so it mints a fresh reference per call — bind it to a
458
461
  * `const` and reuse it, or layer memoization does not apply.
459
462
  */
460
- static layer = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerNoop));
463
+ static layer = (options) => WorkspaceCatalogs.layerWithHooks(ConfigDependencyHooks.layerNoop, options);
461
464
  /**
462
465
  * The opt-in live layer that **does** replay config-dependency `pnpmfile.cjs`
463
466
  * hooks: it wires {@link ConfigDependencyHooks.layerLive}, which dynamically
@@ -469,7 +472,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
469
472
  * the default catalog path stays free of any config-dependency execution.
470
473
  * Parameterized, so bind it to a `const` and reuse it.
471
474
  */
472
- static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
475
+ static layerWithConfigDependencies = (options) => WorkspaceCatalogs.layerWithHooks(ConfigDependencyHooks.layerLive, options);
473
476
  /**
474
477
  * The opt-in layer that replays config-dependency `pnpmfile` hooks in a
475
478
  * `node` **child process**: it wires
@@ -487,7 +490,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
487
490
  * same sanctioned R-widening as `Workspaces.layerWithGit`. Parameterized, so
488
491
  * bind it to a `const` and reuse it.
489
492
  */
490
- static layerWithConfigDependenciesSubprocess = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerSubprocess));
493
+ static layerWithConfigDependenciesSubprocess = (options) => WorkspaceCatalogs.layerWithHooks(ConfigDependencyHooks.layerSubprocess, options);
491
494
  /**
492
495
  * A test double satisfying the full {@link WorkspaceCatalogsShape} with no
493
496
  * filesystem, lockfile read, or hook replay.
@@ -505,9 +508,10 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
505
508
  * `resolveSpecifier` answers from that `CatalogSet`'s own
506
509
  * {@link CatalogSet.resolveSpecifier} — exactly what the live service runs
507
510
  * over its assembled set — so the two stay consistent by construction.
508
- * `releaseAgeGate` and `importerVersions` are **not** derivable from a
509
- * catalog set (the gate comes from release-age keys and hook contributions,
510
- * the importer index from the lockfile's importer blocks — neither is in a
511
+ * `releaseAgeGate`, `importerVersions` and `hookReplays` are **not**
512
+ * derivable from a catalog set (the gate comes from release-age keys and
513
+ * hook contributions, the importer index from the lockfile's importer
514
+ * blocks, the replay record from the hooks layer — none is in a
511
515
  * `CatalogSet`) and always die unless stubbed.
512
516
  *
513
517
  * `refresh` defaults to `Effect.void` honestly: the double holds no memo,
@@ -534,6 +538,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
534
538
  peerDependencyRules: () => unstubbed("peerDependencyRules"),
535
539
  releaseAgeGate: () => unstubbed("releaseAgeGate"),
536
540
  importerVersions: () => unstubbed("importerVersions"),
541
+ hookReplays: () => unstubbed("hookReplays"),
537
542
  refresh: () => Effect.void,
538
543
  ...overrides
539
544
  };
@@ -585,4 +590,4 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
585
590
  };
586
591
 
587
592
  //#endregion
588
- export { CatalogSet, WorkspaceCatalogs };
593
+ export { CatalogSet, WorkspaceCatalogs, injectFromDocument };
@@ -1,8 +1,9 @@
1
1
  import { manifestPatternsOf, pnpmPatternsOf } from "./internal/patterns.js";
2
2
  import { WorkspaceRoot } from "./WorkspaceRoot.js";
3
3
  import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
4
+ import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
4
5
  import { importerVersionsOf } from "./internal/importerVersions.js";
5
- import { CatalogSet, WorkspaceCatalogs } from "./WorkspaceCatalogs.js";
6
+ import { CatalogSet, WorkspaceCatalogs, injectFromDocument } from "./WorkspaceCatalogs.js";
6
7
  import { PackageStateSnapshot, WorkspaceStateSnapshot } from "./WorkspaceStateSnapshot.js";
7
8
  import { Git } from "@effected/git";
8
9
  import { Context, Duration, Effect, Exit, Layer, Option } from "effect";
@@ -12,6 +13,17 @@ import { Yaml } from "@effected/yaml";
12
13
  import { Lockfile, filenameFor } from "@effected/lockfiles";
13
14
 
14
15
  //#region src/WorkspaceSnapshots.ts
16
+ /**
17
+ * Project the live replay record onto the snapshot's `name → version` field:
18
+ * a snapshot is "what the workspace looked like then", and WHERE this machine
19
+ * found each pnpmfile is machine-local provenance that does not belong on a
20
+ * serializable value.
21
+ */
22
+ const hookVersionsOf = (replays) => {
23
+ const versions = {};
24
+ for (const [name, replay] of Object.entries(replays)) versions[name] = replay.version;
25
+ return versions;
26
+ };
15
27
  /** Whether `value` is a non-null, non-array object. */
16
28
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
29
  /** Whether every value in a record is a string — a usable dependency map. */
@@ -98,12 +110,22 @@ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceSna
98
110
  * @public
99
111
  */
100
112
  var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@effected/workspaces/WorkspaceSnapshots") {
101
- /** Builds the service over `Git`, {@link WorkspaceRoot}, {@link WorkspaceDiscovery} and {@link WorkspaceCatalogs}. */
113
+ /**
114
+ * Builds the service over `Git`, {@link WorkspaceRoot}, {@link WorkspaceDiscovery},
115
+ * {@link WorkspaceCatalogs} and {@link ConfigDependencyHooks}.
116
+ *
117
+ * @remarks
118
+ * `ConfigDependencyHooks` is what `at(ref)` replays a ref's
119
+ * `configDependencies` through — at the versions THAT ref declares, which is
120
+ * why the composites hand the same hooks layer to this service and to
121
+ * `WorkspaceCatalogs`. Under `layerNoop` the ref read executes nothing.
122
+ */
102
123
  static make = (options) => Effect.gen(function* () {
103
124
  const git = yield* Git;
104
125
  const roots = yield* WorkspaceRoot;
105
126
  const discovery = yield* WorkspaceDiscovery;
106
127
  const catalogsService = yield* WorkspaceCatalogs;
128
+ const hooks = yield* ConfigDependencyHooks;
107
129
  const seeded = (snapshot) => options?.seedCatalogs === void 0 ? snapshot : snapshot.withSeededCatalogs(options.seedCatalogs);
108
130
  /**
109
131
  * What a manager's lockfile records at the ref: its catalog set and its
@@ -118,15 +140,16 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
118
140
  })), Effect.catch(() => Effect.succeed(EMPTY_LOCKFILE_RECORD)))
119
141
  })));
120
142
  const computeAt = (root, ref) => Effect.gen(function* () {
121
- const pnpmWorkspaceText = yield* git.show(root, ref, "./pnpm-workspace.yaml");
122
- const rootManifestText = yield* git.show(root, ref, "./package.json");
143
+ const [pnpmWorkspaceText, rootManifestText] = yield* Effect.all([git.show(root, ref, "./pnpm-workspace.yaml"), git.show(root, ref, "./package.json")], { concurrency: 2 });
123
144
  const rootManifest = Option.match(rootManifestText, {
124
145
  onNone: () => ({}),
125
146
  onSome: parseJsonObject
126
147
  });
127
148
  let patterns;
128
149
  let inline;
150
+ let injected;
129
151
  let recorded;
152
+ let hookReplays = {};
130
153
  if (Option.isSome(pnpmWorkspaceText)) {
131
154
  const document = yield* Yaml.parse(pnpmWorkspaceText.value).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
132
155
  source: "manifest",
@@ -136,13 +159,17 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
136
159
  const pnpmPatterns = pnpmPatternsOf(document);
137
160
  patterns = pnpmPatterns.length > 0 ? pnpmPatterns : manifestPatternsOf(rootManifest);
138
161
  inline = yield* CatalogSet.fromWorkspaceYaml(pnpmWorkspaceText.value);
162
+ const replayed = yield* injectFromDocument(hooks, root, document, inline);
163
+ injected = replayed.injected;
164
+ hookReplays = replayed.injection.replays;
139
165
  recorded = yield* lockfileRecord(root, ref, "pnpm");
140
166
  } else {
141
167
  patterns = manifestPatternsOf(rootManifest);
142
168
  inline = bunInlineCatalogs(rootManifest);
169
+ injected = CatalogSet.empty();
143
170
  recorded = yield* lockfileRecord(root, ref, "bun");
144
171
  }
145
- const catalogs = CatalogSet.merge(recorded.catalogs, inline);
172
+ const catalogs = CatalogSet.merge(recorded.catalogs, inline, injected);
146
173
  const globs = yield* GlobSet.compile(patterns).pipe(Effect.mapError((error) => new CatalogAssemblyError({
147
174
  source: "manifest",
148
175
  path: error.pattern,
@@ -170,7 +197,8 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
170
197
  return seeded(WorkspaceStateSnapshot.make({
171
198
  packages,
172
199
  catalogs,
173
- importerVersions: recorded.importerVersions
200
+ importerVersions: recorded.importerVersions,
201
+ hookReplays: hookVersionsOf(hookReplays)
174
202
  }));
175
203
  });
176
204
  const atCaches = /* @__PURE__ */ new Map();
@@ -195,6 +223,7 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
195
223
  const packages = yield* discovery.listPackages();
196
224
  const catalogs = yield* catalogsService.set();
197
225
  const importerVersions = yield* catalogsService.importerVersions();
226
+ const hookReplays = yield* catalogsService.hookReplays();
198
227
  const snapshotPackages = packages.map((pkg) => PackageStateSnapshot.make({
199
228
  name: pkg.name,
200
229
  version: pkg.version ?? "",
@@ -207,7 +236,8 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
207
236
  return seeded(WorkspaceStateSnapshot.make({
208
237
  packages: snapshotPackages,
209
238
  catalogs,
210
- importerVersions
239
+ importerVersions,
240
+ hookReplays: hookVersionsOf(hookReplays)
211
241
  }));
212
242
  })
213
243
  };
@@ -117,6 +117,23 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
117
117
  */
118
118
  importerVersions: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String))),
119
119
  /**
120
+ * Which version each declared config dependency's hook was replayed from at
121
+ * this moment, keyed by name.
122
+ *
123
+ * @remarks
124
+ * Versions only — WHERE this machine found each pnpmfile (the `source` of a
125
+ * live `HookReplay`) is machine-local provenance, not part of "what the
126
+ * workspace looked like then", so it stays on the live `HookInjection`
127
+ * diagnostic and off this serializable value. Every fresh read sets the
128
+ * field: `{}` under the no-op layer, which resolves nothing, when the file
129
+ * declares no config dependencies, or on the bun / `package.json` path,
130
+ * where config dependencies do not exist. Absent only when decoding a
131
+ * snapshot serialized before the field existed, so stored values stay
132
+ * readable. Carried through `withSeededCatalogs` and `crossSeed`
133
+ * unchanged, like `importerVersions`.
134
+ */
135
+ hookReplays: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
136
+ /**
120
137
  * Catalogs supplied from OUTSIDE this moment, consulted only when
121
138
  * `catalogs` cannot answer.
122
139
  *
@@ -129,11 +146,14 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
129
146
  * always wins and both halves stay readable.
130
147
  *
131
148
  * The motivating case is a catalog injected by a config-dependency
132
- * `pnpmfile` hook. It is recorded in no committed catalog source, so
133
- * `WorkspaceSnapshots.at(ref)` — which never replays hooks, by design — cannot
134
- * see it, and a `catalog:` specifier against it resolves to nothing on BOTH
135
- * sides of a diff. Seeding the live hook-injected set, or the other side's
136
- * set, restores a declared RANGE without executing any historical code.
149
+ * `pnpmfile` hook. It is recorded in no committed catalog source, so under
150
+ * a non-replaying `ConfigDependencyHooks` layer (`layerNoop`, the default)
151
+ * `WorkspaceSnapshots.at(ref)` cannot see it, and a `catalog:` specifier
152
+ * against it resolves to nothing on BOTH sides of a diff. Seeding the live
153
+ * hook-injected set, or the other side's set, restores a declared RANGE
154
+ * without executing any historical code. Under a replaying layer `at(ref)`
155
+ * replays the ref's own pinned hook and the seed answers only what neither
156
+ * source declared.
137
157
  *
138
158
  * Defaults to absent, which makes the seed inert — exactly the behavior of
139
159
  * every snapshot captured before this field existed.
@@ -280,8 +300,8 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
280
300
  * value, and an accumulating seed would make precedence depend on call
281
301
  * order). {@link WorkspaceStateSnapshot.crossSeed} is the deliberate
282
302
  * exception: it composes the two seeds explicitly, precisely because a bare
283
- * replace would discard a layer-level seed. `catalogs`, `packages` and `importerVersions` are carried through
284
- * unchanged, so what the ref declared is still exactly what it declared.
303
+ * replace would discard a layer-level seed. `catalogs`, `packages`, `importerVersions` and `hookReplays` are
304
+ * carried through unchanged, so what the ref declared is still exactly what it declared.
285
305
  *
286
306
  * The two seeds worth reaching for: the LIVE hook-injected catalog set (from
287
307
  * a `WorkspaceCatalogs` built by one of the config-dependency layers), or the
@@ -311,6 +331,7 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
311
331
  packages: this.packages,
312
332
  catalogs: this.catalogs,
313
333
  ...this.importerVersions === void 0 ? {} : { importerVersions: this.importerVersions },
334
+ ...this.hookReplays === void 0 ? {} : { hookReplays: this.hookReplays },
314
335
  seededCatalogs: seed
315
336
  });
316
337
  }
@@ -326,16 +347,19 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
326
347
  * missing it, at strictly lower precedence than that side's own catalogs, so
327
348
  * a genuine change between the refs still reads as a change.
328
349
  *
329
- * **The limitation is inherent and is not a defect to work around.** A range
330
- * change made purely by bumping the config dependency BETWEEN the two refs is
331
- * suppressed: neither committed source declares the catalog, so each side
332
- * falls back to the other's value and the two agree by construction. Seeding
333
- * the live hook-injected set via
334
- * {@link WorkspaceStateSnapshot.withSeededCatalogs} has the same blind spot
335
- * against history — recovering it would mean replaying each ref's pinned
336
- * config-dependency code, which `at(ref)` will not do. If that case must be
337
- * detected, diff `configDependencies` in `pnpm-workspace.yaml` directly; it
338
- * is the only committed evidence that the injection changed.
350
+ * **What cross-seeding can and cannot see depends on the hooks layer.** A
351
+ * range change made purely by bumping the config dependency BETWEEN the two
352
+ * refs is detected when the snapshots were read under a replaying
353
+ * `ConfigDependencyHooks` layer: `WorkspaceSnapshots.at(ref)` replays each
354
+ * ref's hook at the version that ref's `configDependencies` declares, so
355
+ * each side's OWN catalogs carry that side's injected range and the seed
356
+ * never gets a say. Under `layerNoop` the case stays suppressed — neither
357
+ * committed source declares the catalog, so each side falls back to the
358
+ * other's value and the two agree by construction; there, diff
359
+ * `configDependencies` in `pnpm-workspace.yaml` directly, the only committed
360
+ * evidence that the injection changed. Cross-seeding is harmless under a
361
+ * replaying layer (own catalogs take precedence) and still worthwhile under
362
+ * the no-op one, which is why it stays.
339
363
  *
340
364
  * **The seeding relationship is symmetric; the RETURN ORDER is not.** Each
341
365
  * snapshot is seeded with the other's catalogs, so neither argument is
package/Workspaces.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
2
  import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
3
3
  import { ChangeDetector } from "./ChangeDetector.js";
4
+ import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
4
5
  import { PackageManagerDetector } from "./PackageManagerName.js";
5
6
  import { LockfileReader } from "./LockfileReader.js";
6
7
  import { WorkspaceCatalogs } from "./WorkspaceCatalogs.js";
@@ -10,39 +11,34 @@ import { Effect, Layer, Option } from "effect";
10
11
  import { ExecContext, LocalExec, LocalExecError } from "@effected/commands";
11
12
 
12
13
  //#region src/Workspaces.ts
13
- const compose = (options, catalogsFactory) => {
14
+ const compose = (options, hooks) => {
14
15
  const roots = WorkspaceRoot.layer;
15
16
  const detector = PackageManagerDetector.layer;
16
17
  const discovery = WorkspaceDiscovery.layer(options).pipe(Layer.provide(roots));
17
18
  const lockfiles = LockfileReader.layer(options).pipe(Layer.provide(roots), Layer.provide(detector), Layer.provide(discovery));
18
- const catalogs = catalogsFactory(options).pipe(Layer.provide(roots), Layer.provide(lockfiles));
19
+ const catalogs = WorkspaceCatalogs.layerWithHooks(hooks, options).pipe(Layer.provide(roots), Layer.provide(lockfiles));
19
20
  return Layer.mergeAll(roots, detector, discovery, lockfiles, catalogs);
20
21
  };
21
- const layer = (options) => compose(options, WorkspaceCatalogs.layer);
22
+ const layer = (options) => compose(options, ConfigDependencyHooks.layerNoop);
22
23
  /**
23
- * The git half, over an ALREADY-BUILT core composite.
24
- *
25
- * @remarks
26
- * Taking the core as a parameter rather than building it is what lets the
27
- * config-dependency composites exist at all: the git services are identical
28
- * across all three, and only the catalogs layer underneath differs. Copying
29
- * this graph per variant is exactly what a consumer had to do downstream
30
- * before `layerWithGitAndConfigDependencies` existed.
31
- *
32
- * `core` is threaded in as a value, so the caller's single `layer(options)`
33
- * reference is shared by `ChangeDetector`, `WorkspaceSnapshots` and the merge —
34
- * building it twice here would defeat layer memoization inside one composite.
24
+ * The core composite widened with the git tier: `Git`, `ChangeDetector` and
25
+ * `WorkspaceSnapshots`. It builds the core over `hooks` ITSELF, so the SAME
26
+ * hooks layer reaches `WorkspaceCatalogs` (through `compose`) and
27
+ * `WorkspaceSnapshots` by construction — `at(ref)` replays a ref's config
28
+ * dependencies exactly as the live assembler replays the worktree's: one
29
+ * reference, one memo, one policy for both sides of a diff.
35
30
  */
36
- const withGit = (core, options) => {
31
+ const withGit = (hooks, options) => {
32
+ const core = compose(options, hooks);
37
33
  const git = Git.layer;
38
- return Layer.mergeAll(core, git, ChangeDetector.layer.pipe(Layer.provide(git), Layer.provide(core)), WorkspaceSnapshots.layer(options).pipe(Layer.provide(git), Layer.provide(core)));
34
+ return Layer.mergeAll(core, git, ChangeDetector.layer.pipe(Layer.provide(git), Layer.provide(core)), WorkspaceSnapshots.layer(options).pipe(Layer.provide(git), Layer.provide(hooks), Layer.provide(core)));
39
35
  };
40
- const layerWithGit = (options) => withGit(layer(options), options);
36
+ const layerWithGit = (options) => withGit(ConfigDependencyHooks.layerNoop, options);
41
37
  const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDiscovery.workspaceResolver);
42
- const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
43
- const layerWithConfigDependenciesSubprocess = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependenciesSubprocess);
44
- const layerWithGitAndConfigDependencies = (options) => withGit(layerWithConfigDependencies(options), options);
45
- const layerWithGitAndConfigDependenciesSubprocess = (options) => withGit(layerWithConfigDependenciesSubprocess(options), options);
38
+ const layerWithConfigDependencies = (options) => compose(options, ConfigDependencyHooks.layerLive);
39
+ const layerWithConfigDependenciesSubprocess = (options) => compose(options, ConfigDependencyHooks.layerSubprocess);
40
+ const layerWithGitAndConfigDependencies = (options) => withGit(ConfigDependencyHooks.layerLive, options);
41
+ const layerWithGitAndConfigDependenciesSubprocess = (options) => withGit(ConfigDependencyHooks.layerSubprocess, options);
46
42
  const resolverLayer = (options) => resolvers.pipe(Layer.provide(layerWithConfigDependencies(options)));
47
43
  const resolveManifest = Effect.fn("Workspaces.resolveManifest")(function* (manifest, options) {
48
44
  return yield* manifest.resolve().pipe(Effect.provide(resolverLayer(options)));
@@ -226,6 +222,43 @@ var Workspaces = class {
226
222
  */
227
223
  static layerWithGitAndConfigDependenciesSubprocess = layerWithGitAndConfigDependenciesSubprocess;
228
224
  /**
225
+ * The git composite over a **caller-supplied** hooks layer — the same graph
226
+ * as {@link Workspaces.layerWithGit} and its two config-dependency
227
+ * variants, with the `ConfigDependencyHooks` policy chosen by the caller.
228
+ *
229
+ * @remarks
230
+ * The three fixed git composites are this function applied to
231
+ * {@link ConfigDependencyHooks.layerNoop}, {@link ConfigDependencyHooks.layerLive}
232
+ * and {@link ConfigDependencyHooks.layerSubprocess}. It exists for the fourth
233
+ * policy: {@link ConfigDependencyHooks.layerFrom}, the hermetic seam, which a
234
+ * snapshot-backed test otherwise cannot reach without rebuilding the whole
235
+ * git graph by hand — the copy `layerWithGitAndConfigDependencies` was added
236
+ * to delete. The ONE hooks reference is handed to both `WorkspaceCatalogs`
237
+ * and `WorkspaceSnapshots`, so `at(ref)` and `worktree()` cannot drift on
238
+ * which policy they replay. A hooks layer carrying its own requirement (the
239
+ * subprocess variant's `ChildProcessSpawner`) threads it through to the
240
+ * composite's `R` unchanged.
241
+ *
242
+ * **Bind the result to a `const`.**
243
+ *
244
+ * @param hooks - The `ConfigDependencyHooks` layer to wire on both sides.
245
+ * @param options - The composite options, as for {@link Workspaces.layerWithGit}.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { ConfigDependencyHooks, Workspaces } from "@effected/workspaces";
250
+ *
251
+ * // A test pinning what at(ref) replays for two declared plugin versions.
252
+ * const KitLayer = Workspaces.layerWithGitAndHooks(
253
+ * ConfigDependencyHooks.layerFrom({
254
+ * "@scope/plugin@1.0.0": "/fixtures/plugin-1/pnpmfile.mjs",
255
+ * "@scope/plugin@2.0.0": "/fixtures/plugin-2/pnpmfile.mjs",
256
+ * }),
257
+ * );
258
+ * ```
259
+ */
260
+ static layerWithGitAndHooks = withGit;
261
+ /**
229
262
  * This package's implementation of `@effected/commands`' `LocalExec`
230
263
  * contract: how to run a project-local binary here.
231
264
  *