@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.
- package/ConfigDependencyHooks.js +208 -170
- package/PeerCheck.js +27 -20
- package/WorkspaceCatalogs.js +62 -57
- package/WorkspaceSnapshots.js +37 -7
- package/WorkspaceStateSnapshot.js +41 -17
- package/Workspaces.js +55 -22
- package/index.d.ts +301 -81
- package/internal/configDependencyResolution.js +307 -0
- package/internal/patterns.js +2 -1
- package/internal/peerPatterns.js +35 -0
- package/internal/workspaceYaml.js +44 -0
- package/package.json +1 -1
package/ConfigDependencyHooks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalize } from "./internal/catalogs.js";
|
|
2
|
-
import {
|
|
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
|
|
156
|
-
* (`process.argv.slice(1)`
|
|
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
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* skip discrimination (`
|
|
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 [
|
|
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
|
|
269
|
+
for (const [name, url] of JSON.parse(entriesJson)) {
|
|
220
270
|
let loaded;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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:
|
|
326
|
-
* (in process, no subprocess)
|
|
327
|
-
* seed, in declaration order.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
*
|
|
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
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* `
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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> <
|
|
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
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
321
|
+
* Whether the effective rules suppress this row, the way pnpm would.
|
|
319
322
|
*
|
|
320
323
|
* @remarks
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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 = (
|
|
335
|
-
if (found === null) return
|
|
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) {
|