@effected/workspaces 0.9.6 → 0.10.1

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,10 @@
1
1
  import { normalize } from "./internal/catalogs.js";
2
- import { Context, Effect, Layer, Predicate, Result } from "effect";
2
+ import { Context, Duration, Effect, Layer, Predicate, Result, Schema } from "effect";
3
3
  import { CatalogAssemblyError } from "@effected/npm";
4
4
  import { join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { Run } from "@effected/commands";
7
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
6
8
 
7
9
  //#region src/ConfigDependencyHooks.ts
8
10
  /** Whether `value` is a non-null, non-array object. */
@@ -97,6 +99,141 @@ const updateConfigOf = (mod) => {
97
99
  }
98
100
  };
99
101
  /**
102
+ * The subprocess replay program {@link ConfigDependencyHooks.layerSubprocess}
103
+ * hands to `node --input-type=module -e`.
104
+ *
105
+ * @remarks
106
+ * A **static** string constant, deliberately: a bundler compiles a *computed*
107
+ * dynamic `import()` into a context module that cannot resolve a runtime path
108
+ * (`Cannot find module 'file:///…'`), so the computed import has to run in a
109
+ * child process whose program text carries **no interpolated runtime value** —
110
+ * the workspace root, the seed and the dependency names all arrive via argv
111
+ * (`process.argv.slice(1)` under `-e`), never spliced into the script.
112
+ *
113
+ * The script mirrors this module's in-process semantics exactly so the two
114
+ * layers are drop-in interchangeable: `pnpmfile.mjs` before `pnpmfile.cjs`
115
+ * (pnpm 11's loader order), the `ERR_MODULE_NOT_FOUND`-for-the-candidate-itself
116
+ * skip discrimination (`err.url` equality), the same hook-locator shapes, the
117
+ * same tolerant threading of returned data (`configOf` / `finiteNumberOr` /
118
+ * `stringArrayOr` clones below), and the same synchronous hook call. Only the
119
+ * *mechanism* failures are reported: the script prints one final line of JSON —
120
+ * `{ ok: true, config }` on success, `{ ok: false, name, message, stack? }`
121
+ * naming the offending dependency on a load/replay failure — and exits through
122
+ * the write callback so the payload is flushed even if a hook left the event
123
+ * loop occupied. The final-line framing tolerates a hook's own `console.log`
124
+ * noise on stdout.
125
+ */
126
+ const REPLAY_SCRIPT = `
127
+ const [root, seedJson, ...names] = process.argv.slice(1);
128
+ const { pathToFileURL } = await import("node:url");
129
+ const { join } = await import("node:path");
130
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
131
+ const finiteNumberOr = (value, fallback) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
132
+ const stringArrayOr = (value, fallback) =>
133
+ Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
134
+ const configOf = (value, fallback) =>
135
+ isObject(value)
136
+ ? {
137
+ catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
138
+ catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
139
+ minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
140
+ minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
141
+ }
142
+ : fallback;
143
+ const failure = (name, cause) => ({
144
+ ok: false,
145
+ name,
146
+ message: cause instanceof Error ? cause.message : String(cause),
147
+ ...(cause instanceof Error && typeof cause.stack === "string" ? { stack: cause.stack } : {}),
148
+ });
149
+ const replay = async () => {
150
+ let catalog = {};
151
+ const catalogs = {};
152
+ for (const [name, entries] of Object.entries(JSON.parse(seedJson))) {
153
+ if (name === "default") catalog = { ...entries };
154
+ else catalogs[name] = { ...entries };
155
+ }
156
+ let config = { catalog, catalogs, minimumReleaseAge: undefined, minimumReleaseAgeExclude: undefined };
157
+ for (const name of names) {
158
+ let loaded;
159
+ let found = false;
160
+ for (const filename of ["pnpmfile.mjs", "pnpmfile.cjs"]) {
161
+ const candidate = pathToFileURL(join(root, "node_modules", ".pnpm-config", name, filename)).href;
162
+ try {
163
+ loaded = await import(candidate);
164
+ found = true;
165
+ break;
166
+ } catch (cause) {
167
+ const url =
168
+ isObject(cause) && cause.code === "ERR_MODULE_NOT_FOUND" && typeof cause.url === "string"
169
+ ? cause.url
170
+ : undefined;
171
+ if (url === candidate) continue;
172
+ return failure(name, cause);
173
+ }
174
+ }
175
+ if (!found) continue;
176
+ let hook;
177
+ for (const candidate of [loaded, isObject(loaded) ? loaded.default : undefined]) {
178
+ if (!isObject(candidate)) continue;
179
+ if (isObject(candidate.hooks) && typeof candidate.hooks.updateConfig === "function") {
180
+ hook = candidate.hooks.updateConfig;
181
+ break;
182
+ }
183
+ if (typeof candidate.updateConfig === "function") {
184
+ hook = candidate.updateConfig;
185
+ break;
186
+ }
187
+ }
188
+ if (hook === undefined) continue;
189
+ try {
190
+ config = configOf(hook(config), config);
191
+ } catch (cause) {
192
+ return failure(name, cause);
193
+ }
194
+ }
195
+ return { ok: true, config };
196
+ };
197
+ const payload = await replay();
198
+ process.stdout.write("\\n" + JSON.stringify(payload) + "\\n", () => process.exit(0));
199
+ `;
200
+ /**
201
+ * The ceiling on one subprocess replay. A config dependency's `updateConfig`
202
+ * reads and rewrites a config object — no install work, no network — so thirty
203
+ * seconds is generous. Without a ceiling a pnpmfile that loops or awaits a
204
+ * promise that never settles hangs `inject`, and through it the one memoized
205
+ * {@link WorkspaceCatalogs} assemble pass every catalog read blocks on. Expiry
206
+ * kills the child (`Run` scopes it) and surfaces as a `CommandFailedError`
207
+ * through the same typed transport-failure path as any other mechanism failure.
208
+ */
209
+ const REPLAY_TIMEOUT = Duration.seconds(30);
210
+ /**
211
+ * The subprocess protocol payload — a single JSON line near the end of the
212
+ * child's stdout, framed and parsed by `Run.jsonLine`, which scans lines from
213
+ * the end for the first that decodes (so a hook logging after the payload —
214
+ * e.g. from `process.on("exit", ...)` — cannot displace it). The `ok`
215
+ * discriminant is what keeps an accidental log line from satisfying the
216
+ * envelope. The envelope is strict (a payload without a usable
217
+ * `ok` discriminant is a mechanism failure, typed); the `config` slice inside a
218
+ * success stays `Unknown` because a hook's returned *data* is tolerantly
219
+ * threaded (`configOf`), never fatal.
220
+ */
221
+ const ReplayPayload = Schema.Union([Schema.Struct({
222
+ ok: Schema.Literal(true),
223
+ config: Schema.Unknown
224
+ }), Schema.Struct({
225
+ ok: Schema.Literal(false),
226
+ name: Schema.optionalKey(Schema.String),
227
+ message: Schema.optionalKey(Schema.String),
228
+ stack: Schema.optionalKey(Schema.String)
229
+ })]);
230
+ /** Rebuild the subprocess's serialized failure as an `Error`, preserving the child-side stack when it carried one. */
231
+ const replayFailureCause = (payload) => {
232
+ const error = new Error(payload.message ?? "config dependency hook replay failed");
233
+ if (payload.stack !== void 0) error.stack = payload.stack;
234
+ return error;
235
+ };
236
+ /**
100
237
  * Replays a workspace's `configDependencies` `updateConfig` hooks over the inline
101
238
  * catalogs — the opt-in seam that lets hook-injected catalogs participate in
102
239
  * assembly.
@@ -189,6 +326,95 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
189
326
  releaseAge: releaseAgeOf(config)
190
327
  };
191
328
  }) });
329
+ /**
330
+ * The subprocess layer: replays each config dependency's pnpmfile in a `node`
331
+ * child process instead of an in-process dynamic `import()`, with identical
332
+ * typed semantics to {@link ConfigDependencyHooks.layerLive} — the two are
333
+ * drop-in interchangeable.
334
+ *
335
+ * @remarks
336
+ * `layerLive` computes the `import()` path at runtime, and a bundler (rspack,
337
+ * for one) compiles a *computed* dynamic import into a context module that
338
+ * throws `Cannot find module 'file:///…'` at runtime — so in any bundled
339
+ * consumer, a GitHub Action above all, the in-process replay is unreachable.
340
+ * This layer keeps every computed load out of the bundle graph: the replay
341
+ * program is a **static** string constant passed via argv
342
+ * (`node --input-type=module -e <script> <root> <seed> <...names>`), and the
343
+ * child process performs the computed imports where no bundler rewrote them.
344
+ * A subprocess also keeps config-dependency code out of the consumer's own
345
+ * process.
346
+ *
347
+ * The contract's semantics are unchanged, not the downstream fail-open shape:
348
+ * an empty `configDependencies` returns the seed without spawning anything; a
349
+ * `..` path segment in a dependency name fails typed **before** any spawn; a
350
+ * missing pnpmfile (neither `.mjs` nor `.cjs`) is the one legitimate skip,
351
+ * discriminated inside the child by `err.url` equality exactly as
352
+ * `layerLive` discriminates in process; any other load failure — a syntax
353
+ * error, a throwing top level, an `ERR_MODULE_NOT_FOUND` for a module the
354
+ * pnpmfile itself imports — and a hook that throws when called are serialized
355
+ * back per-name and surface typed as a `hooks`-source `CatalogAssemblyError`
356
+ * naming that dependency. A hook's returned *data* stays tolerantly threaded
357
+ * (last well-formed write wins), never fatal. Spawn and transport failures —
358
+ * `node` absent, a non-zero exit without a result payload, unparseable
359
+ * output — fail typed too, never a defect and never a silent skip.
360
+ *
361
+ * Two bounds this layer imposes that `layerLive` cannot: the replay is
362
+ * given thirty seconds (a pnpmfile that loops or awaits forever fails typed
363
+ * instead of hanging the memoized assemble pass — a subprocess is killable,
364
+ * while `layerLive`'s in-process synchronous hook call is not interruptible
365
+ * by any means, so the asymmetry is inherent, not a parity violation), and
366
+ * the child's stdout is captured under `Run.jsonLine`'s 16 MiB default
367
+ * ceiling (a hook that logs more than that fails typed as `tooLarge`, where
368
+ * `layerLive` — which captures nothing — would succeed).
369
+ *
370
+ * Catalog folding and normalization stay in the **parent** (the same
371
+ * `@pnpm/catalogs`-derived path `layerLive` uses); the child returns only the
372
+ * raw threaded config slice, since the script cannot import kit code.
373
+ *
374
+ * Requires core's `ChildProcessSpawner`, resolved when the layer is built —
375
+ * the consumer provides it once at the edge (`@effect/platform-node`'s
376
+ * `NodeServices.layer`), the same discharge `@effected/git` uses. Wired by
377
+ * `WorkspaceCatalogs.layerWithConfigDependenciesSubprocess` /
378
+ * `Workspaces.layerWithConfigDependenciesSubprocess`.
379
+ */
380
+ static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
381
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
382
+ return { inject: (root, configDependencies, seed) => Effect.gen(function* () {
383
+ const names = Object.keys(configDependencies);
384
+ if (names.length === 0) return {
385
+ catalogs: seed,
386
+ releaseAge: {}
387
+ };
388
+ for (const name of names) if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
389
+ source: "hooks",
390
+ path: name,
391
+ cause: /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${name}`)
392
+ }));
393
+ const command = ChildProcess.make("node", [
394
+ "--input-type=module",
395
+ "-e",
396
+ REPLAY_SCRIPT,
397
+ root,
398
+ JSON.stringify(seed),
399
+ ...names
400
+ ]);
401
+ const payload = yield* Run.jsonLine(command, ReplayPayload, { timeout: REPLAY_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.catch((cause) => Effect.fail(new CatalogAssemblyError({
402
+ source: "hooks",
403
+ path: root,
404
+ cause
405
+ }))));
406
+ if (payload.ok === false) return yield* Effect.fail(new CatalogAssemblyError({
407
+ source: "hooks",
408
+ path: payload.name ?? root,
409
+ cause: replayFailureCause(payload)
410
+ }));
411
+ const config = configOf(payload.config, seedToConfig(seed));
412
+ return {
413
+ catalogs: configToEntries(config),
414
+ releaseAge: releaseAgeOf(config)
415
+ };
416
+ }) };
417
+ }));
192
418
  };
193
419
 
194
420
  //#endregion
package/README.md CHANGED
@@ -223,6 +223,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
223
223
  ## Features
224
224
 
225
225
  - `Workspaces.layer` / `Workspaces.layerWithGit` / `Workspaces.resolvers` — the composite layers, split on requirements rather than feature flags: a filesystem, a filesystem plus a subprocess, and the two `@effected/npm` resolver contracts.
226
+ - `Workspaces.layerWithConfigDependencies` / `Workspaces.layerWithConfigDependenciesSubprocess` — opt in to replaying a pnpm config dependency's pnpmfile hooks, which is what lets catalogs and `releaseAgeGate()` see the entries a hook injects. The default layer runs no config-dependency code at all. The two spellings differ only in where the replay happens: in process, or in a `node` child process for a consumer whose code is bundled (a GitHub Action, say), where the in-process form's computed dynamic import cannot survive the bundler. The subprocess form asks for core's `ChildProcessSpawner`; `WorkspaceCatalogs` carries the same pair.
226
227
  - `Workspaces.resolverLayer` / `Workspaces.resolveManifest` — the one-call manifest-resolution path: a fresh, unmemoized layer per call so root discovery follows your cwd, and one-shot resolution of a whole `Manifest` against the real workspace.
227
228
  - `WorkspaceRoot` — root discovery from a `cwd`, over `WORKSPACE_MARKERS`.
228
229
  - `WorkspaceDiscovery` — package enumeration with a bounded descent for segment-crossing `packages/**` patterns, per-package lookup and the `makeTest` / `layerTest` in-memory test doubles.
@@ -428,6 +428,24 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
428
428
  */
429
429
  static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
430
430
  /**
431
+ * The opt-in layer that replays config-dependency `pnpmfile` hooks in a
432
+ * `node` **child process**: it wires
433
+ * {@link ConfigDependencyHooks.layerSubprocess} in place of the in-process
434
+ * `layerLive`.
435
+ *
436
+ * @remarks
437
+ * Same typed semantics as {@link WorkspaceCatalogs.layerWithConfigDependencies}
438
+ * — the two hook layers are drop-in interchangeable — but the replay's
439
+ * computed dynamic `import()` runs in the subprocess, so it survives bundling
440
+ * (a bundler compiles a computed in-process `import()` into a context module
441
+ * that cannot resolve at runtime — every bundled GitHub Action hits this).
442
+ * The cost is one extra requirement: core's `ChildProcessSpawner`, provided
443
+ * once at the edge (`@effect/platform-node`'s `NodeServices.layer`) — the
444
+ * same sanctioned R-widening as `Workspaces.layerWithGit`. Parameterized, so
445
+ * bind it to a `const` and reuse it.
446
+ */
447
+ static layerWithConfigDependenciesSubprocess = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerSubprocess));
448
+ /**
431
449
  * A test double satisfying the full {@link WorkspaceCatalogsShape} with no
432
450
  * filesystem, lockfile read, or hook replay.
433
451
  *
package/Workspaces.js CHANGED
@@ -26,6 +26,7 @@ const layerWithGit = (options) => {
26
26
  };
27
27
  const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDiscovery.workspaceResolver);
28
28
  const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
29
+ const layerWithConfigDependenciesSubprocess = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependenciesSubprocess);
29
30
  const resolverLayer = (options) => resolvers.pipe(Layer.provide(layerWithConfigDependencies(options)));
30
31
  const resolveManifest = Effect.fn("Workspaces.resolveManifest")(function* (manifest, options) {
31
32
  return yield* manifest.resolve().pipe(Effect.provide(resolverLayer(options)));
@@ -107,6 +108,32 @@ var Workspaces = class {
107
108
  */
108
109
  static layerWithConfigDependencies = layerWithConfigDependencies;
109
110
  /**
111
+ * The git-free composite with config-dependency hook replay in a `node`
112
+ * **child process** —
113
+ * {@link WorkspaceCatalogs.layerWithConfigDependenciesSubprocess} in place of
114
+ * the in-process replay.
115
+ *
116
+ * @remarks
117
+ * Same typed semantics as {@link Workspaces.layerWithConfigDependencies}; the
118
+ * difference is mechanism, and it matters in exactly one environment class: a
119
+ * **bundled** consumer. The in-process replay's computed dynamic `import()`
120
+ * is compiled by bundlers (rspack among them) into a context module that
121
+ * throws `Cannot find module 'file:///…'` at runtime, which makes
122
+ * `WorkspaceCatalogs.releaseAgeGate()` unreachable from any bundled GitHub
123
+ * Action. Here the computed import runs inside a `node` child process whose
124
+ * program text is a static string handed over argv, so nothing computed
125
+ * enters the bundle graph.
126
+ *
127
+ * The extra requirement is core's `ChildProcessSpawner`, provided once at
128
+ * the edge (`@effect/platform-node`'s `NodeServices.layer`) — the same
129
+ * sanctioned R-widening as {@link Workspaces.layerWithGit}, and the reason
130
+ * this is a separate composite rather than a flag: a consumer that keeps the
131
+ * in-process replay should not have to be able to spawn a subprocess.
132
+ *
133
+ * **Bind the result to a `const`.**
134
+ */
135
+ static layerWithConfigDependenciesSubprocess = layerWithConfigDependenciesSubprocess;
136
+ /**
110
137
  * The git-free composite plus {@link ChangeDetector} and
111
138
  * {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
112
139
  *
@@ -116,7 +143,8 @@ var Workspaces = class {
116
143
  * never detects changes or reads at a ref should not have to be able to
117
144
  * spawn a subprocess. The consumer provides `ChildProcessSpawner` once at
118
145
  * the edge (`@effect/platform-node`'s `NodeServices.layer`); a test
119
- * provides `Layer.succeed(Git, …)` and needs no repository on disk.
146
+ * provides `Git.layerTest({ })` git's own shipped double, whose
147
+ * unstubbed members die named — and needs no repository on disk.
120
148
  */
121
149
  static layerWithGit = layerWithGit;
122
150
  /**
@@ -165,9 +193,14 @@ var Workspaces = class {
165
193
  * import { Workspaces } from "@effected/workspaces";
166
194
  * import { Layer } from "effect";
167
195
  *
196
+ * // Bound to consts per the warning above: each factory call mints a
197
+ * // fresh layer reference, and layers memoize by reference.
198
+ * const LocalExecLayer = Workspaces.localExecLayer();
199
+ * const WorkspacesLayer = Workspaces.layer();
200
+ *
168
201
  * const AppLayer = ToolDiscovery.layer.pipe(
169
- * Layer.provide(Workspaces.localExecLayer()),
170
- * Layer.provide(Workspaces.layer()),
202
+ * Layer.provide(LocalExecLayer),
203
+ * Layer.provide(WorkspacesLayer),
171
204
  * Layer.provide(NodeServices.layer),
172
205
  * );
173
206
  * ```
package/index.d.ts CHANGED
@@ -4,8 +4,8 @@ import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, Manif
4
4
  import { GlobPattern } from "@effected/glob";
5
5
  import { Lockfile, LockfileFramingError, LockfileIntegrity, LockfileParseError, ResolvedPackage, WorkspaceManifest } from "@effected/lockfiles";
6
6
  import { Package } from "@effected/package-json";
7
- import { LocalExec } from "@effected/commands";
8
7
  import { ChildProcessSpawner } from "effect/unstable/process";
8
+ import { LocalExec } from "@effected/commands";
9
9
  //#region src/WorkspacePackage.d.ts
10
10
  declare const PublishConfig_base: Schema.Class<PublishConfig, Schema.Struct<{
11
11
  /** Scoped-package visibility. Its presence overrides `private`. */
@@ -896,6 +896,58 @@ declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
896
896
  * `WorkspaceCatalogs.layerWithConfigDependencies`.
897
897
  */
898
898
  static readonly layerLive: Layer.Layer<ConfigDependencyHooks>;
899
+ /**
900
+ * The subprocess layer: replays each config dependency's pnpmfile in a `node`
901
+ * child process instead of an in-process dynamic `import()`, with identical
902
+ * typed semantics to {@link ConfigDependencyHooks.layerLive} — the two are
903
+ * drop-in interchangeable.
904
+ *
905
+ * @remarks
906
+ * `layerLive` computes the `import()` path at runtime, and a bundler (rspack,
907
+ * for one) compiles a *computed* dynamic import into a context module that
908
+ * throws `Cannot find module 'file:///…'` at runtime — so in any bundled
909
+ * consumer, a GitHub Action above all, the in-process replay is unreachable.
910
+ * This layer keeps every computed load out of the bundle graph: the replay
911
+ * program is a **static** string constant passed via argv
912
+ * (`node --input-type=module -e <script> <root> <seed> <...names>`), and the
913
+ * child process performs the computed imports where no bundler rewrote them.
914
+ * A subprocess also keeps config-dependency code out of the consumer's own
915
+ * process.
916
+ *
917
+ * The contract's semantics are unchanged, not the downstream fail-open shape:
918
+ * an empty `configDependencies` returns the seed without spawning anything; a
919
+ * `..` path segment in a dependency name fails typed **before** any spawn; a
920
+ * missing pnpmfile (neither `.mjs` nor `.cjs`) is the one legitimate skip,
921
+ * discriminated inside the child by `err.url` equality exactly as
922
+ * `layerLive` discriminates in process; any other load failure — a syntax
923
+ * error, a throwing top level, an `ERR_MODULE_NOT_FOUND` for a module the
924
+ * pnpmfile itself imports — and a hook that throws when called are serialized
925
+ * back per-name and surface typed as a `hooks`-source `CatalogAssemblyError`
926
+ * naming that dependency. A hook's returned *data* stays tolerantly threaded
927
+ * (last well-formed write wins), never fatal. Spawn and transport failures —
928
+ * `node` absent, a non-zero exit without a result payload, unparseable
929
+ * output — fail typed too, never a defect and never a silent skip.
930
+ *
931
+ * Two bounds this layer imposes that `layerLive` cannot: the replay is
932
+ * given thirty seconds (a pnpmfile that loops or awaits forever fails typed
933
+ * instead of hanging the memoized assemble pass — a subprocess is killable,
934
+ * while `layerLive`'s in-process synchronous hook call is not interruptible
935
+ * by any means, so the asymmetry is inherent, not a parity violation), and
936
+ * the child's stdout is captured under `Run.jsonLine`'s 16 MiB default
937
+ * ceiling (a hook that logs more than that fails typed as `tooLarge`, where
938
+ * `layerLive` — which captures nothing — would succeed).
939
+ *
940
+ * Catalog folding and normalization stay in the **parent** (the same
941
+ * `@pnpm/catalogs`-derived path `layerLive` uses); the child returns only the
942
+ * raw threaded config slice, since the script cannot import kit code.
943
+ *
944
+ * Requires core's `ChildProcessSpawner`, resolved when the layer is built —
945
+ * the consumer provides it once at the edge (`@effect/platform-node`'s
946
+ * `NodeServices.layer`), the same discharge `@effected/git` uses. Wired by
947
+ * `WorkspaceCatalogs.layerWithConfigDependenciesSubprocess` /
948
+ * `Workspaces.layerWithConfigDependenciesSubprocess`.
949
+ */
950
+ static readonly layerSubprocess: Layer.Layer<ConfigDependencyHooks, never, ChildProcessSpawner.ChildProcessSpawner>;
899
951
  }
900
952
  //#endregion
901
953
  //#region src/DependencyGraph.d.ts
@@ -2097,6 +2149,24 @@ declare class WorkspaceCatalogs extends WorkspaceCatalogs_base {
2097
2149
  * Parameterized, so bind it to a `const` and reuse it.
2098
2150
  */
2099
2151
  static readonly layerWithConfigDependencies: (options?: WorkspaceCatalogsOptions) => Layer.Layer<WorkspaceCatalogs, never, WorkspaceRoot | LockfileReader | FileSystem.FileSystem | Path.Path>;
2152
+ /**
2153
+ * The opt-in layer that replays config-dependency `pnpmfile` hooks in a
2154
+ * `node` **child process**: it wires
2155
+ * {@link ConfigDependencyHooks.layerSubprocess} in place of the in-process
2156
+ * `layerLive`.
2157
+ *
2158
+ * @remarks
2159
+ * Same typed semantics as {@link WorkspaceCatalogs.layerWithConfigDependencies}
2160
+ * — the two hook layers are drop-in interchangeable — but the replay's
2161
+ * computed dynamic `import()` runs in the subprocess, so it survives bundling
2162
+ * (a bundler compiles a computed in-process `import()` into a context module
2163
+ * that cannot resolve at runtime — every bundled GitHub Action hits this).
2164
+ * The cost is one extra requirement: core's `ChildProcessSpawner`, provided
2165
+ * once at the edge (`@effect/platform-node`'s `NodeServices.layer`) — the
2166
+ * same sanctioned R-widening as `Workspaces.layerWithGit`. Parameterized, so
2167
+ * bind it to a `const` and reuse it.
2168
+ */
2169
+ static readonly layerWithConfigDependenciesSubprocess: (options?: WorkspaceCatalogsOptions) => Layer.Layer<WorkspaceCatalogs, never, WorkspaceRoot | LockfileReader | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
2100
2170
  /**
2101
2171
  * A test double satisfying the full {@link WorkspaceCatalogsShape} with no
2102
2172
  * filesystem, lockfile read, or hook replay.
@@ -2569,6 +2639,32 @@ declare class Workspaces {
2569
2639
  * **Bind the result to a `const`.**
2570
2640
  */
2571
2641
  static readonly layerWithConfigDependencies: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path>;
2642
+ /**
2643
+ * The git-free composite with config-dependency hook replay in a `node`
2644
+ * **child process** —
2645
+ * {@link WorkspaceCatalogs.layerWithConfigDependenciesSubprocess} in place of
2646
+ * the in-process replay.
2647
+ *
2648
+ * @remarks
2649
+ * Same typed semantics as {@link Workspaces.layerWithConfigDependencies}; the
2650
+ * difference is mechanism, and it matters in exactly one environment class: a
2651
+ * **bundled** consumer. The in-process replay's computed dynamic `import()`
2652
+ * is compiled by bundlers (rspack among them) into a context module that
2653
+ * throws `Cannot find module 'file:///…'` at runtime, which makes
2654
+ * `WorkspaceCatalogs.releaseAgeGate()` unreachable from any bundled GitHub
2655
+ * Action. Here the computed import runs inside a `node` child process whose
2656
+ * program text is a static string handed over argv, so nothing computed
2657
+ * enters the bundle graph.
2658
+ *
2659
+ * The extra requirement is core's `ChildProcessSpawner`, provided once at
2660
+ * the edge (`@effect/platform-node`'s `NodeServices.layer`) — the same
2661
+ * sanctioned R-widening as {@link Workspaces.layerWithGit}, and the reason
2662
+ * this is a separate composite rather than a flag: a consumer that keeps the
2663
+ * in-process replay should not have to be able to spawn a subprocess.
2664
+ *
2665
+ * **Bind the result to a `const`.**
2666
+ */
2667
+ static readonly layerWithConfigDependenciesSubprocess: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
2572
2668
  /**
2573
2669
  * The git-free composite plus {@link ChangeDetector} and
2574
2670
  * {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
@@ -2579,7 +2675,8 @@ declare class Workspaces {
2579
2675
  * never detects changes or reads at a ref should not have to be able to
2580
2676
  * spawn a subprocess. The consumer provides `ChildProcessSpawner` once at
2581
2677
  * the edge (`@effect/platform-node`'s `NodeServices.layer`); a test
2582
- * provides `Layer.succeed(Git, …)` and needs no repository on disk.
2678
+ * provides `Git.layerTest({ })` git's own shipped double, whose
2679
+ * unstubbed members die named — and needs no repository on disk.
2583
2680
  */
2584
2681
  static readonly layerWithGit: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices | ChangeDetector | WorkspaceSnapshots | Git, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
2585
2682
  /**
@@ -2628,9 +2725,14 @@ declare class Workspaces {
2628
2725
  * import { Workspaces } from "@effected/workspaces";
2629
2726
  * import { Layer } from "effect";
2630
2727
  *
2728
+ * // Bound to consts per the warning above: each factory call mints a
2729
+ * // fresh layer reference, and layers memoize by reference.
2730
+ * const LocalExecLayer = Workspaces.localExecLayer();
2731
+ * const WorkspacesLayer = Workspaces.layer();
2732
+ *
2631
2733
  * const AppLayer = ToolDiscovery.layer.pipe(
2632
- * Layer.provide(Workspaces.localExecLayer()),
2633
- * Layer.provide(Workspaces.layer()),
2734
+ * Layer.provide(LocalExecLayer),
2735
+ * Layer.provide(WorkspacesLayer),
2634
2736
  * Layer.provide(NodeServices.layer),
2635
2737
  * );
2636
2738
  * ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
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,11 +46,11 @@
46
46
  "./package.json": "./package.json"
47
47
  },
48
48
  "dependencies": {
49
- "@effected/commands": "^0.2.1",
50
- "@effected/git": "^0.5.2",
49
+ "@effected/commands": "^0.3.1",
50
+ "@effected/git": "^0.6.0",
51
51
  "@effected/glob": "^0.2.2",
52
52
  "@effected/lockfiles": "^0.3.2",
53
- "@effected/npm": "^0.8.2",
53
+ "@effected/npm": "^0.8.3",
54
54
  "@effected/package-json": "^0.7.3",
55
55
  "@effected/semver": "^0.3.2",
56
56
  "@effected/walker": "^0.3.4",