@effected/workspaces 0.23.0 → 0.24.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,7 +1,7 @@
1
1
  import { normalize } from "./internal/catalogs.js";
2
- import { Context, Duration, Effect, Layer, Predicate, Result, Schema } from "effect";
2
+ import { lookupPnpmfiles, resolvePnpmfiles } from "./internal/configDependencyResolution.js";
3
+ import { Context, Duration, Effect, Layer, Predicate, Schema } from "effect";
3
4
  import { CatalogAssemblyError } from "@effected/npm";
4
- import { join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { Run } from "@effected/commands";
7
7
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -28,32 +28,6 @@ const NoPeerDependencyRules = Object.freeze({
28
28
  });
29
29
  /** Internal alias, kept short at the many call sites in this module. */
30
30
  const NO_PEER_RULES = NoPeerDependencyRules;
31
- /** Whether `value` is a non-null, non-array object. */
32
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
33
- /**
34
- * The file URL a Node ESM `ERR_MODULE_NOT_FOUND` reports as unresolvable, or
35
- * `undefined` when the failure is anything else.
36
- *
37
- * @remarks
38
- * Node sets `err.url` (an own string property) to the offending module's URL.
39
- * For an absent *entry* module it is that entry's own URL; for a module the entry
40
- * `import`s that fails to resolve it is the **nested** module's URL. That
41
- * distinction is exactly what separates "this pnpmfile does not exist" (a
42
- * legitimate skip, `err.url` equal to the candidate) from "the pnpmfile's own
43
- * import failed to resolve" (a real error, `err.url` pointing elsewhere) — a
44
- * discrimination `err.code` alone cannot make, since both raise
45
- * `ERR_MODULE_NOT_FOUND`. Verified against Node's ESM loader: the entry-absent
46
- * case yields `err.url === pathToFileURL(candidate).href`; the nested case yields
47
- * the nested module's URL.
48
- */
49
- const moduleNotFoundUrl = (cause) => isObject(cause) && cause.code === "ERR_MODULE_NOT_FOUND" && typeof cause.url === "string" ? cause.url : void 0;
50
- /**
51
- * Whether a config-dependency `name` carries a `..` path segment. A scoped name
52
- * legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
53
- * it would traverse out of `.pnpm-config` and feed an attacker-chosen path to the
54
- * dynamic `import()` below.
55
- */
56
- const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
57
31
  /** Turn the seed record into the pnpm hook config, with the default catalog split out under `catalog`. */
58
32
  const seedToConfig = (seed, rules) => {
59
33
  const catalogs = {};
@@ -85,10 +59,10 @@ const stringArrayOr = (value, fallback) => Array.isArray(value) && value.every((
85
59
  * write wins**, exactly as pnpm's single mutable config object behaves.
86
60
  */
87
61
  const configOf = (value, fallback) => {
88
- if (!isObject(value)) return fallback;
62
+ if (!Predicate.isObject(value)) return fallback;
89
63
  return {
90
- catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
91
- catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
64
+ catalog: Predicate.isObject(value.catalog) ? value.catalog : fallback.catalog,
65
+ catalogs: Predicate.isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
92
66
  minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
93
67
  minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
94
68
  peerDependencyRules: peerRulesOr(value.peerDependencyRules, fallback.peerDependencyRules)
@@ -101,9 +75,9 @@ const configOf = (value, fallback) => {
101
75
  * Each axis is normalized independently: a hook that rewrites `allowedVersions`
102
76
  * and leaves `ignoreMissing` alone must not blank the latter.
103
77
  */
104
- const stringRecordOr = (value, fallback) => isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
78
+ const stringRecordOr = (value, fallback) => Predicate.isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
105
79
  const peerRulesOr = (value, fallback) => {
106
- if (!isObject(value)) return fallback;
80
+ if (!Predicate.isObject(value)) return fallback;
107
81
  const allowed = stringRecordOr(value.allowedVersions, fallback?.allowedVersions);
108
82
  const ignoreMissing = stringArrayOr(value.ignoreMissing, fallback?.ignoreMissing);
109
83
  const allowAny = stringArrayOr(value.allowAny, fallback?.allowAny);
@@ -129,21 +103,96 @@ const releaseAgeOf = (config) => ({
129
103
  const configToEntries = (config) => {
130
104
  const raw = { ...config.catalogs };
131
105
  if (Object.keys(config.catalog).length > 0) raw.default = {
132
- ...isObject(raw.default) ? raw.default : {},
106
+ ...Predicate.isObject(raw.default) ? raw.default : {},
133
107
  ...config.catalog
134
108
  };
135
109
  return normalize(raw);
136
110
  };
137
111
  /** Locate the `updateConfig` hook across the CJS/ESM export shapes a `pnpmfile.cjs` can present. */
138
112
  const updateConfigOf = (mod) => {
139
- for (const candidate of [mod, isObject(mod) ? mod.default : void 0]) {
140
- if (!isObject(candidate)) continue;
113
+ for (const candidate of [mod, Predicate.isObject(mod) ? mod.default : void 0]) {
114
+ if (!Predicate.isObject(candidate)) continue;
141
115
  const hooks = candidate.hooks;
142
- if (isObject(hooks) && Predicate.isFunction(hooks.updateConfig)) return hooks.updateConfig;
116
+ if (Predicate.isObject(hooks) && Predicate.isFunction(hooks.updateConfig)) return hooks.updateConfig;
143
117
  if (Predicate.isFunction(candidate.updateConfig)) return candidate.updateConfig;
144
118
  }
145
119
  };
146
120
  /**
121
+ * The seed pass-through: what every layer returns for an empty
122
+ * `configDependencies`, and — with `replays` — what a replaying layer returns
123
+ * when every resolved dependency ships no pnpmfile.
124
+ */
125
+ const untouched = (seed, rules, replays = {}) => ({
126
+ catalogs: seed,
127
+ releaseAge: {},
128
+ peerDependencyRules: rules ?? NO_PEER_RULES,
129
+ replays
130
+ });
131
+ /** The per-name replay record of a resolved set — every resolved dependency, pnpmfile or not. */
132
+ const replaysOf = (pnpmfiles) => {
133
+ const replays = {};
134
+ for (const { name, version, source } of pnpmfiles) replays[name] = {
135
+ version,
136
+ source
137
+ };
138
+ return replays;
139
+ };
140
+ /** Project the final threaded config into the injection, alongside the replay record. */
141
+ const injectionOf = (config, pnpmfiles) => ({
142
+ catalogs: configToEntries(config),
143
+ releaseAge: releaseAgeOf(config),
144
+ peerDependencyRules: peerRulesOf(config),
145
+ replays: replaysOf(pnpmfiles)
146
+ });
147
+ /**
148
+ * Replay already-resolved pnpmfiles IN PROCESS over the seed, in order — the
149
+ * one implementation {@link ConfigDependencyHooks.layerLive} and
150
+ * {@link ConfigDependencyHooks.layerFrom} share; only how the paths were
151
+ * found differs.
152
+ *
153
+ * @remarks
154
+ * Each file is loaded via a dynamic `import()` of its file URL. Because the
155
+ * caller has already established that the file exists (listed by the
156
+ * ladder's directory read, or by the caller's own word under `layerFrom`),
157
+ * ANY load failure — a syntax error, a throwing top level, an unreadable
158
+ * file, an `ERR_MODULE_NOT_FOUND` for a module the pnpmfile itself imports —
159
+ * is a real error and surfaces typed as a
160
+ * `hooks`-source `CatalogAssemblyError` naming the dependency, never a silent
161
+ * skip. A file exposing no `updateConfig` hook contributes nothing. The hook
162
+ * is called synchronously and its returned data threaded tolerantly
163
+ * (`configOf`); a hook that throws fails typed.
164
+ */
165
+ const replayInProcess = (pnpmfiles, seed, rules) => Effect.gen(function* () {
166
+ let config = seedToConfig(seed, rules);
167
+ for (const { name, path } of pnpmfiles) {
168
+ if (path === void 0) continue;
169
+ const url = pathToFileURL(path).href;
170
+ const loaded = yield* Effect.tryPromise({
171
+ try: () => import(
172
+ /* webpackIgnore: true */
173
+ url
174
+ ),
175
+ catch: (cause) => new CatalogAssemblyError({
176
+ source: "hooks",
177
+ path: name,
178
+ cause
179
+ })
180
+ });
181
+ const updateConfig = updateConfigOf(loaded);
182
+ if (updateConfig === void 0) continue;
183
+ const currentConfig = config;
184
+ config = yield* Effect.try({
185
+ try: () => configOf(updateConfig(currentConfig), currentConfig),
186
+ catch: (cause) => new CatalogAssemblyError({
187
+ source: "hooks",
188
+ path: name,
189
+ cause
190
+ })
191
+ });
192
+ }
193
+ return injectionOf(config, pnpmfiles);
194
+ });
195
+ /**
147
196
  * The subprocess replay program {@link ConfigDependencyHooks.layerSubprocess}
148
197
  * hands to `node --input-type=module -e`.
149
198
  *
@@ -152,13 +201,16 @@ const updateConfigOf = (mod) => {
152
201
  * dynamic `import()` into a context module that cannot resolve a runtime path
153
202
  * (`Cannot find module 'file:///…'`), so the computed import has to run in a
154
203
  * child process whose program text carries **no interpolated runtime value** —
155
- * the workspace root, the seed and the dependency names all arrive via argv
156
- * (`process.argv.slice(1)` under `-e`), never spliced into the script.
204
+ * the seed, the rules seed and the resolved `[name, fileUrl]` pairs all
205
+ * arrive via argv (`<seed> <rules> <entries>`, read as `process.argv.slice(1)`
206
+ * under `-e`), never spliced into the script.
157
207
  *
158
- * The script mirrors this module's in-process semantics exactly so the two
159
- * layers are drop-in interchangeable: `pnpmfile.mjs` before `pnpmfile.cjs`
160
- * (pnpm 11's loader order), the `ERR_MODULE_NOT_FOUND`-for-the-candidate-itself
161
- * skip discrimination (`err.url` equality), the same hook-locator shapes, the
208
+ * The PARENT resolves each config dependency to the pnpmfile of its declared
209
+ * version (`internal/configDependencyResolution.ts`) and hands the child only
210
+ * `file:` URLs of existing files, so the child performs no lookup, no path
211
+ * conversion and no skip discrimination: any `import()` failure is a real
212
+ * load failure. The script otherwise mirrors `replayInProcess` exactly so the
213
+ * two layers are drop-in interchangeable: the same hook-locator shapes, the
162
214
  * same tolerant threading of returned data (`configOf` / `finiteNumberOr` /
163
215
  * `stringArrayOr` clones below), and the same synchronous hook call. Only the
164
216
  * *mechanism* failures are reported: the script prints one final line of JSON —
@@ -169,9 +221,7 @@ const updateConfigOf = (mod) => {
169
221
  * noise on stdout.
170
222
  */
171
223
  const REPLAY_SCRIPT = `
172
- const [root, seedJson, rulesJson, ...names] = process.argv.slice(1);
173
- const { pathToFileURL } = await import("node:url");
174
- const { join } = await import("node:path");
224
+ const [seedJson, rulesJson, entriesJson] = process.argv.slice(1);
175
225
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
176
226
  const finiteNumberOr = (value, fallback) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
177
227
  const stringArrayOr = (value, fallback) =>
@@ -216,25 +266,13 @@ const replay = async () => {
216
266
  minimumReleaseAgeExclude: undefined,
217
267
  peerDependencyRules: JSON.parse(rulesJson),
218
268
  };
219
- for (const name of names) {
269
+ for (const [name, url] of JSON.parse(entriesJson)) {
220
270
  let loaded;
221
- let found = false;
222
- for (const filename of ["pnpmfile.mjs", "pnpmfile.cjs"]) {
223
- const candidate = pathToFileURL(join(root, "node_modules", ".pnpm-config", name, filename)).href;
224
- try {
225
- loaded = await import(candidate);
226
- found = true;
227
- break;
228
- } catch (cause) {
229
- const url =
230
- isObject(cause) && cause.code === "ERR_MODULE_NOT_FOUND" && typeof cause.url === "string"
231
- ? cause.url
232
- : undefined;
233
- if (url === candidate) continue;
234
- return failure(name, cause);
235
- }
271
+ try {
272
+ loaded = await import(url);
273
+ } catch (cause) {
274
+ return failure(name, cause);
236
275
  }
237
- if (!found) continue;
238
276
  let hook;
239
277
  for (const candidate of [loaded, isObject(loaded) ? loaded.default : undefined]) {
240
278
  if (!isObject(candidate)) continue;
@@ -301,12 +339,21 @@ const replayFailureCause = (payload) => {
301
339
  * assembly.
302
340
  *
303
341
  * @remarks
304
- * A contract-only service: it declares the shape and ships two layers, never a
342
+ * A contract-only service: it declares the shape and ships layers, never a
305
343
  * baked-in default. {@link ConfigDependencyHooks.layerNoop} executes no
306
344
  * config-dependency code (it returns the seed untouched) and is what the default
307
345
  * {@link WorkspaceCatalogs} layer wires; {@link ConfigDependencyHooks.layerLive}
308
- * dynamically imports each `pnpmfile.cjs` and replays it, and is wired only by the
309
- * explicit `WorkspaceCatalogs.layerWithConfigDependencies` opt-in.
346
+ * dynamically imports each pnpmfile and replays it, and is wired only by the
347
+ * explicit `WorkspaceCatalogs.layerWithConfigDependencies` opt-in;
348
+ * {@link ConfigDependencyHooks.layerSubprocess} is its bundler-safe twin; and
349
+ * {@link ConfigDependencyHooks.layerFrom} is the hermetic test seam that
350
+ * replays caller-supplied files with no resolution.
351
+ *
352
+ * Every replaying layer replays the pnpmfile of the version each
353
+ * `configDependencies` entry **declares** — `layerLive` and `layerSubprocess`
354
+ * resolve it through `node_modules/.pnpm-config` and then the pnpm store,
355
+ * failing closed when neither holds that version — so a snapshot read at a
356
+ * past ref replays that ref's pinned hook, not today's.
310
357
  *
311
358
  * @public
312
359
  */
@@ -316,83 +363,85 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
316
363
  * config dependency. The default {@link WorkspaceCatalogs} layer wires this, so
317
364
  * the default catalog path provably executes no config-dependency code.
318
365
  */
319
- static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed, rules) => Effect.succeed({
320
- catalogs: seed,
321
- releaseAge: {},
322
- peerDependencyRules: rules ?? NO_PEER_RULES
323
- }) });
366
+ static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed, rules) => Effect.succeed(untouched(seed, rules)) });
324
367
  /**
325
- * The live layer: dynamically imports each config dependency's `pnpmfile.cjs`
326
- * (in process, no subprocess) and replays its `updateConfig` hook over the
327
- * seed, in declaration order. A dependency without a `pnpmfile.cjs` contributes
328
- * nothing; a dependency whose file fails to load or replay fails typed with a
329
- * `hooks`-source `CatalogAssemblyError`, never a silent skip.
368
+ * The live layer: resolves each config dependency to the pnpmfile of its
369
+ * **declared** version, dynamically imports it (in process, no subprocess)
370
+ * and replays its `updateConfig` hook over the seed, in declaration order.
371
+ * A dependency that ships no pnpmfile contributes nothing; a dependency
372
+ * whose declared version is installed nowhere, or whose file fails to load
373
+ * or replay, fails typed with a `hooks`-source `CatalogAssemblyError`, never
374
+ * a silent skip.
330
375
  *
331
376
  * @remarks
332
- * Runtime-coupled by design, not node-exclusive. The `import()` below loads
377
+ * Resolution is a ladder, fail-closed, with no registry fetch ever:
378
+ * `<root>/node_modules/.pnpm-config/<name>` when its `package.json`
379
+ * `version` equals the declared version (the text before the first `+` of
380
+ * the `configDependencies` value), else the pnpm store's
381
+ * `links/<name>/<declared>/*\/node_modules/<name>` (the store is located
382
+ * from `node_modules/.modules.yaml`, the realpath of any `.pnpm-config`
383
+ * entry, then `$PNPM_HOME` / `$XDG_DATA_HOME` / the platform default), else
384
+ * a typed error naming the package, the declared version, what is
385
+ * installed, the stores searched, and the remediation
386
+ * (`pnpm add --config <name>@<version>` in a throwaway workspace). In the
387
+ * chosen directory the first of `pnpmfile.mjs`, `pnpmfile.cjs`,
388
+ * `pnpmfile.js` present in one directory listing is loaded; a listed but
389
+ * unreadable file fails typed at import time, never as "ships no hook".
390
+ *
391
+ * Runtime-coupled by design, not node-exclusive. The `import()` loads
333
392
  * **and executes** a config dependency's pnpmfile in-process — code execution,
334
- * not IO, so no `FileSystem` / `Path` service abstracts it. The `node:path` and
335
- * `node:url` imports (`join`, `pathToFileURL`) exist only to build the URL that
336
- * `import()` consumes; node and bun both implement those builtins and dynamic
337
- * import, so this layer runs on either runtime. Only ever wired by
338
- * `WorkspaceCatalogs.layerWithConfigDependencies`.
393
+ * not IO, so no `FileSystem` / `Path` service abstracts it — and the ladder
394
+ * reads the real disk through `node:fs` for the same reason: the store it
395
+ * searches is real even when the caller's `FileSystem` is virtual. Only ever
396
+ * wired by `WorkspaceCatalogs.layerWithConfigDependencies` and the
397
+ * `Workspaces` composites of the same name.
339
398
  */
340
399
  static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
341
- const names = Object.keys(configDependencies);
342
- if (names.length === 0) return {
343
- catalogs: seed,
344
- releaseAge: {},
345
- peerDependencyRules: rules ?? NO_PEER_RULES
346
- };
347
- let config = seedToConfig(seed, rules);
348
- for (const name of names) {
349
- if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
350
- source: "hooks",
351
- path: name,
352
- cause: /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${name}`)
353
- }));
354
- let loaded;
355
- let found = false;
356
- for (const filename of ["pnpmfile.mjs", "pnpmfile.cjs"]) {
357
- const candidatePath = join(root, "node_modules", ".pnpm-config", name, filename);
358
- const candidateUrl = pathToFileURL(candidatePath).href;
359
- const result = yield* Effect.result(Effect.tryPromise({
360
- try: () => import(
361
- /* webpackIgnore: true */
362
- candidateUrl
363
- ),
364
- catch: (cause) => cause
365
- }));
366
- if (Result.isSuccess(result)) {
367
- loaded = result.success;
368
- found = true;
369
- break;
370
- }
371
- if (moduleNotFoundUrl(result.failure) === candidateUrl) continue;
372
- return yield* Effect.fail(new CatalogAssemblyError({
373
- source: "hooks",
374
- path: name,
375
- cause: result.failure
376
- }));
377
- }
378
- if (!found) continue;
379
- const updateConfig = updateConfigOf(loaded);
380
- if (updateConfig === void 0) continue;
381
- const currentConfig = config;
382
- config = yield* Effect.try({
383
- try: () => configOf(updateConfig(currentConfig), currentConfig),
384
- catch: (cause) => new CatalogAssemblyError({
385
- source: "hooks",
386
- path: name,
387
- cause
388
- })
389
- });
390
- }
391
- return {
392
- catalogs: configToEntries(config),
393
- releaseAge: releaseAgeOf(config),
394
- peerDependencyRules: peerRulesOf(config)
395
- };
400
+ if (Object.keys(configDependencies).length === 0) return untouched(seed, rules);
401
+ const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies);
402
+ return yield* replayInProcess(pnpmfiles, seed, rules);
403
+ }) });
404
+ /**
405
+ * The hermetic layer: replays caller-supplied pnpmfiles, keyed
406
+ * `"<name>@<version>"` → absolute path, with **no resolution** — it consults
407
+ * neither `node_modules/.pnpm-config` nor the pnpm store, and never the
408
+ * effect `FileSystem` service, so it works unchanged under an
409
+ * `@effected/memfs` volume.
410
+ *
411
+ * @remarks
412
+ * The test seam for anything that replays hooks: a suite can pin what
413
+ * `at(ref)` replays for a ref declaring `plugin@1.0.0` versus one declaring
414
+ * `plugin@2.0.0` by mapping both keys to fixture files, with no store on
415
+ * disk. The version half of the key is the declared version — the text
416
+ * before the first `+` of the `configDependencies` value — so a
417
+ * `<version>+<integrity>` spec and a bare `<version>` spec look up the same
418
+ * entry.
419
+ *
420
+ * Same typed semantics as {@link ConfigDependencyHooks.layerLive} past the
421
+ * lookup: the same in-process `import()`, the same hook-locator shapes, the
422
+ * same tolerant threading, the same `hooks`-source attribution. A declared
423
+ * `(name, version)` with no entry fails closed, the same shape as an
424
+ * uninstalled version under `layerLive`; an empty `configDependencies`
425
+ * returns the seed untouched. A `..` segment in a name is refused before
426
+ * any lookup.
427
+ *
428
+ * @param entries - `"<name>@<version>"` → the absolute path of the pnpmfile
429
+ * to replay for that declared version.
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * import { ConfigDependencyHooks } from "@effected/workspaces";
434
+ *
435
+ * const hooks = ConfigDependencyHooks.layerFrom({
436
+ * "@scope/plugin@1.0.0": "/fixtures/plugin-1/pnpmfile.mjs",
437
+ * "@scope/plugin@2.0.0": "/fixtures/plugin-2/pnpmfile.mjs",
438
+ * });
439
+ * ```
440
+ */
441
+ static layerFrom = (entries) => Layer.succeed(ConfigDependencyHooks, { inject: (_root, configDependencies, seed, rules) => Effect.gen(function* () {
442
+ if (Object.keys(configDependencies).length === 0) return untouched(seed, rules);
443
+ const pnpmfiles = yield* lookupPnpmfiles(entries, configDependencies);
444
+ return yield* replayInProcess(pnpmfiles, seed, rules);
396
445
  }) });
397
446
  /**
398
447
  * The subprocess layer: replays each config dependency's pnpmfile in a `node`
@@ -407,24 +456,26 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
407
456
  * consumer, a GitHub Action above all, the in-process replay is unreachable.
408
457
  * This layer keeps every computed load out of the bundle graph: the replay
409
458
  * program is a **static** string constant passed via argv
410
- * (`node --input-type=module -e <script> <root> <seed> <...names>`), and the
459
+ * (`node --input-type=module -e <script> <seed> <rules> <entries>`), and the
411
460
  * child process performs the computed imports where no bundler rewrote them.
412
461
  * A subprocess also keeps config-dependency code out of the consumer's own
413
462
  * process.
414
463
  *
415
- * The contract's semantics are unchanged, not the downstream fail-open shape:
416
- * an empty `configDependencies` returns the seed without spawning anything; a
417
- * `..` path segment in a dependency name fails typed **before** any spawn; a
418
- * missing pnpmfile (neither `.mjs` nor `.cjs`) is the one legitimate skip,
419
- * discriminated inside the child by `err.url` equality exactly as
420
- * `layerLive` discriminates in process; any other load failure — a syntax
421
- * error, a throwing top level, an `ERR_MODULE_NOT_FOUND` for a module the
422
- * pnpmfile itself imports — and a hook that throws when called are serialized
423
- * back per-name and surface typed as a `hooks`-source `CatalogAssemblyError`
424
- * naming that dependency. A hook's returned *data* stays tolerantly threaded
425
- * (last well-formed write wins), never fatal. Spawn and transport failures —
426
- * `node` absent, a non-zero exit without a result payload, unparseable
427
- * output — fail typed too, never a defect and never a silent skip.
464
+ * The declared-version resolution is the PARENT's: the same ladder as
465
+ * `layerLive` (`.pnpm-config` when it holds the declared version, else the
466
+ * pnpm store, else a typed fail-closed error) runs here, and the child
467
+ * receives the resolved `[name, fileUrl]` pairs as one JSON argv
468
+ * argument. An empty `configDependencies`, or one whose dependencies all
469
+ * ship no pnpmfile, returns the seed without spawning anything; a `..` path
470
+ * segment in a dependency name fails typed **before** any spawn; a
471
+ * load failure in the child — a syntax error, a throwing top level, an
472
+ * `ERR_MODULE_NOT_FOUND` for a module the pnpmfile itself imports — and a
473
+ * hook that throws when called are serialized back per-name and surface
474
+ * typed as a `hooks`-source `CatalogAssemblyError` naming that dependency.
475
+ * A hook's returned *data* stays tolerantly threaded (last well-formed write
476
+ * wins), never fatal. Spawn and transport failures — `node` absent, a
477
+ * non-zero exit without a result payload, unparseable output — fail typed
478
+ * too, never a defect and never a silent skip.
428
479
  *
429
480
  * Two bounds this layer imposes that `layerLive` cannot: the replay is
430
481
  * given thirty seconds (a pnpmfile that loops or awaits forever fails typed
@@ -448,25 +499,17 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
448
499
  static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
449
500
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
450
501
  return { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
451
- const names = Object.keys(configDependencies);
452
- if (names.length === 0) return {
453
- catalogs: seed,
454
- releaseAge: {},
455
- peerDependencyRules: rules ?? NO_PEER_RULES
456
- };
457
- for (const name of names) if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
458
- source: "hooks",
459
- path: name,
460
- cause: /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${name}`)
461
- }));
502
+ if (Object.keys(configDependencies).length === 0) return untouched(seed, rules);
503
+ const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies);
504
+ const loadable = pnpmfiles.flatMap(({ name, path }) => path === void 0 ? [] : [[name, pathToFileURL(path).href]]);
505
+ if (loadable.length === 0) return untouched(seed, rules, replaysOf(pnpmfiles));
462
506
  const command = ChildProcess.make("node", [
463
507
  "--input-type=module",
464
508
  "-e",
465
509
  REPLAY_SCRIPT,
466
- root,
467
510
  JSON.stringify(seed),
468
511
  JSON.stringify(rules ?? NO_PEER_RULES),
469
- ...names
512
+ JSON.stringify(loadable)
470
513
  ]);
471
514
  const payload = yield* Run.jsonLine(command, ReplayPayload, { timeout: REPLAY_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.catch((cause) => Effect.fail(new CatalogAssemblyError({
472
515
  source: "hooks",
@@ -478,12 +521,7 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
478
521
  path: payload.name ?? root,
479
522
  cause: replayFailureCause(payload)
480
523
  }));
481
- const config = configOf(payload.config, seedToConfig(seed, rules));
482
- return {
483
- catalogs: configToEntries(config),
484
- releaseAge: releaseAgeOf(config),
485
- peerDependencyRules: peerRulesOf(config)
486
- };
524
+ return injectionOf(configOf(payload.config, seedToConfig(seed, rules)), pnpmfiles);
487
525
  }) };
488
526
  }));
489
527
  };
package/PeerCheck.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { indexInstances, rootInstances } from "./internal/roots.js";
2
+ import { peerNameMatcher } from "./internal/peerPatterns.js";
2
3
  import { Result, Schema } from "effect";
3
4
  import { Range, SemVer } from "@effected/semver";
4
5
 
@@ -187,10 +188,12 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
187
188
  static run(lockfile, options) {
188
189
  const keySupplied = options !== void 0 && "peerDependencyRules" in options;
189
190
  const rules = options?.peerDependencyRules;
190
- const axesUnapplied = rules !== void 0 && (rules.ignoreMissing.length > 0 || rules.allowAny.length > 0);
191
- const rulesApplied = keySupplied && !axesUnapplied;
192
- const allowed = rules?.allowedVersions ?? {};
193
- const unverified = rulesApplied ? [] : ["peerRulesNotApplied"];
191
+ const policy = {
192
+ allowed: rules?.allowedVersions ?? {},
193
+ ignoreMissing: peerNameMatcher(rules?.ignoreMissing ?? []),
194
+ allowAny: peerNameMatcher(rules?.allowAny ?? [])
195
+ };
196
+ const unverified = keySupplied ? [] : ["peerRulesNotApplied"];
194
197
  if (!supportsPeerResolution(lockfile.format)) return PeerCheck.make({
195
198
  supported: false,
196
199
  unsatisfied: [],
@@ -208,7 +211,7 @@ var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
208
211
  unresolved.push(importer.path);
209
212
  continue;
210
213
  }
211
- collect(importer.path, walksFrom(roots), byId, rows, seen, allowed);
214
+ collect(importer.path, walksFrom(roots), byId, rows, seen, policy);
212
215
  }
213
216
  if (lockfile.packages.some((pkg) => pkg.unresolvedEdges.length > 0)) unverified.push("unresolvedEdge");
214
217
  return PeerCheck.make({
@@ -244,7 +247,7 @@ const walksFrom = (roots) => roots._tag === "own" ? [{
244
247
  *
245
248
  * @internal
246
249
  */
247
- const collect = (importerPath, roots, byId, rows, seen, allowed) => {
250
+ const collect = (importerPath, roots, byId, rows, seen, policy) => {
248
251
  const visited = /* @__PURE__ */ new Set();
249
252
  const queue = [...roots];
250
253
  while (queue.length > 0) {
@@ -257,7 +260,7 @@ const collect = (importerPath, roots, byId, rows, seen, allowed) => {
257
260
  const optional = current.instance.peerDependenciesMeta[peer]?.optional === true;
258
261
  const verdict = judge(current.instance, peer, wanted, optional, byId);
259
262
  if (verdict === void 0) continue;
260
- if (suppressedByRule(allowed, current.instance.name, peer, verdict.found)) continue;
263
+ if (suppressedByRule(policy, current.instance.name, peer, verdict.found)) continue;
261
264
  const key = `${importerPath}\u0000${peer}\u0000${current.instance.instanceId}`;
262
265
  if (seen.has(key)) continue;
263
266
  seen.add(key);
@@ -315,27 +318,31 @@ const ruleParentName = (parent) => {
315
318
  return at > 0 ? parent.slice(0, at) : parent;
316
319
  };
317
320
  /**
318
- * Whether a rule permits the version that actually resolved for this peer.
321
+ * Whether the effective rules suppress this row, the way pnpm would.
319
322
  *
320
323
  * @remarks
321
- * Only `allowedVersions` is consulted. `ignoreMissing` and `allowAny` are
322
- * carried through the seam **unmeasured and unwired** — an unmeasured
323
- * suppression is exactly what produced the false positives this work removes,
324
- * so no code acts on them until someone measures them. Non-empty, they make the
325
- * whole report `"peerRulesNotApplied"` (see {@link PeerCheck.run}) rather than
326
- * being ignored here.
324
+ * The three axes partition on `found`, and the partition is the measured
325
+ * no-cross rule (pnpm 12.5.1, `__test__/fixtures/peers/allowany/` and
326
+ * `ignoremissing/`):
327
327
  *
328
- * A rule cannot rescue a peer that resolved to nothing: with no version there
329
- * is nothing for the rule to permit, and pnpm's own `missing` section is not
330
- * suppressed by `allowedVersions` either.
328
+ * - **Nothing resolved** (`found === null`): only `ignoreMissing` can hide the
329
+ * row, by matching the peer name. `allowedVersions` cannot — with no version
330
+ * there is nothing for a range to permit, and pnpm's `missing` section is not
331
+ * suppressed by it either — and neither can `allowAny`
332
+ * (`ignoremissing/peers-check-allowany-react.json` keeps all three rows).
333
+ * - **Something resolved outside the range**: `allowAny` hides it by name, and
334
+ * `allowedVersions` hides it when a matching key's range covers the found
335
+ * version. `ignoreMissing` never does
336
+ * (`allowany/peers-check-ignoremissing-react-redux.json` keeps both rows).
331
337
  *
332
338
  * @internal
333
339
  */
334
- const suppressedByRule = (allowed, parentName, peer, found) => {
335
- if (found === null) return false;
340
+ const suppressedByRule = (policy, parentName, peer, found) => {
341
+ if (found === null) return policy.ignoreMissing(peer);
342
+ if (policy.allowAny(peer)) return true;
336
343
  const version = SemVer.parseResult(found);
337
344
  if (Result.isFailure(version)) return false;
338
- for (const [key, permitted] of Object.entries(allowed)) {
345
+ for (const [key, permitted] of Object.entries(policy.allowed)) {
339
346
  const separator = key.indexOf(">");
340
347
  if (separator === 0) continue;
341
348
  if (separator === -1) {