@effected/workspaces 0.22.1 → 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 +12 -12
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { Array, Duration, Effect, Exit, Option, Predicate, Result } from "effect";
|
|
2
|
+
import { CatalogAssemblyError, PackageManagerCache } from "@effected/npm";
|
|
3
|
+
import { Yaml } from "@effected/yaml";
|
|
4
|
+
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
//#region src/internal/configDependencyResolution.ts
|
|
9
|
+
/** The pnpmfile filenames a config dependency may ship, in the order pnpm tries them. */
|
|
10
|
+
const PNPMFILE_CANDIDATES = [
|
|
11
|
+
"pnpmfile.mjs",
|
|
12
|
+
"pnpmfile.cjs",
|
|
13
|
+
"pnpmfile.js"
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* The version part of a `configDependencies` spec: the text before the first
|
|
17
|
+
* `+` (`0.9.0+sha512-…` → `0.9.0`); a bare `0.9.0` is returned whole.
|
|
18
|
+
*/
|
|
19
|
+
const declaredVersionOf = (spec) => {
|
|
20
|
+
const plus = spec.indexOf("+");
|
|
21
|
+
return plus === -1 ? spec : spec.slice(0, plus);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Whether a config-dependency `name` carries a `..` path segment. A scoped name
|
|
25
|
+
* legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
|
|
26
|
+
* it would traverse out of `.pnpm-config` and feed an attacker-chosen path to the
|
|
27
|
+
* dynamic `import()`.
|
|
28
|
+
*/
|
|
29
|
+
const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
|
|
30
|
+
/** The typed `hooks`-source failure every rung of the ladder reports through. */
|
|
31
|
+
const hooksError = (path, cause) => new CatalogAssemblyError({
|
|
32
|
+
source: "hooks",
|
|
33
|
+
path,
|
|
34
|
+
cause
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* The declared `(name, version)` pairs of a `configDependencies` map, in
|
|
38
|
+
* declaration order, with EVERY name validated before any path is built from
|
|
39
|
+
* one: a `..` segment fails typed. Shared by both resolvers so the refusal and
|
|
40
|
+
* the version split cannot drift between them.
|
|
41
|
+
*/
|
|
42
|
+
const declaredEntries = (configDependencies) => {
|
|
43
|
+
const entries = Object.entries(configDependencies).map(([name, spec]) => ({
|
|
44
|
+
name,
|
|
45
|
+
version: declaredVersionOf(spec)
|
|
46
|
+
}));
|
|
47
|
+
const traversal = entries.find((entry) => hasTraversalSegment(entry.name));
|
|
48
|
+
return traversal === void 0 ? Effect.succeed(entries) : Effect.fail(hooksError(traversal.name, /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${traversal.name}`)));
|
|
49
|
+
};
|
|
50
|
+
/** Whether a `node:fs` rejection means "nothing there" (as opposed to a real IO failure). */
|
|
51
|
+
const isAbsent = (cause) => Predicate.isObject(cause) && (cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
|
52
|
+
/**
|
|
53
|
+
* Run a `node:fs/promises` call, mapping an absent target to `Option.none()`
|
|
54
|
+
* and ANY other rejection (`EACCES`, `EIO`, …) to a typed `hooks` error
|
|
55
|
+
* attributed to `path` — never a silent skip.
|
|
56
|
+
*/
|
|
57
|
+
const ioOrNone = (path, run) => Effect.tryPromise({
|
|
58
|
+
try: run,
|
|
59
|
+
catch: (cause) => cause
|
|
60
|
+
}).pipe(Effect.map(Option.some), Effect.catch((cause) => isAbsent(cause) ? Effect.succeed(Option.none()) : Effect.fail(hooksError(path, cause))));
|
|
61
|
+
/**
|
|
62
|
+
* The `version` of the `package.json` in `dir`: `None` when the manifest is
|
|
63
|
+
* absent, `Some("")` when present but carrying no string version (so it can
|
|
64
|
+
* never match a declared version), typed on any other IO failure or on
|
|
65
|
+
* unparseable JSON — a manifest that exists but cannot be read is not
|
|
66
|
+
* evidence of absence.
|
|
67
|
+
*/
|
|
68
|
+
const manifestVersion = (name, dir) => ioOrNone(name, () => readFile(join(dir, "package.json"), "utf8")).pipe(Effect.flatMap((text) => {
|
|
69
|
+
if (Option.isNone(text)) return Effect.succeed(Option.none());
|
|
70
|
+
return Effect.try({
|
|
71
|
+
try: () => JSON.parse(text.value),
|
|
72
|
+
catch: (cause) => hooksError(name, cause)
|
|
73
|
+
}).pipe(Effect.map((parsed) => Option.some(Predicate.isObject(parsed) && typeof parsed.version === "string" ? parsed.version : "")));
|
|
74
|
+
}));
|
|
75
|
+
/** Directory entries of `dir`, or `[]` when it is absent; typed on any other failure. */
|
|
76
|
+
const entriesOf = (path, dir) => ioOrNone(path, () => readdir(dir)).pipe(Effect.map(Option.getOrElse(() => [])));
|
|
77
|
+
/** The `<root>/node_modules/.pnpm-config` directory. */
|
|
78
|
+
const pnpmConfigDir = (root) => join(root, "node_modules", ".pnpm-config");
|
|
79
|
+
/**
|
|
80
|
+
* The store directory a realpath'd `.pnpm-config` entry lives under: the
|
|
81
|
+
* parent of the ancestor directory named `links`. `None` when the resolved
|
|
82
|
+
* path is not under a `links` tree (a non-store installation).
|
|
83
|
+
*/
|
|
84
|
+
const storeOfLinkedPath = (resolved) => {
|
|
85
|
+
let current = resolved;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const parent = dirname(current);
|
|
88
|
+
if (parent === current) return Option.none();
|
|
89
|
+
if (basename(current) === "links") return Option.some(parent);
|
|
90
|
+
current = parent;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Rung 1 of store discovery: the `storeDir` key of
|
|
95
|
+
* `<root>/node_modules/.modules.yaml`. Absent in a config-only install; a
|
|
96
|
+
* malformed file or a missing key contributes nothing (discovery is
|
|
97
|
+
* best-effort — the final error lists what was searched); a non-absent IO
|
|
98
|
+
* failure is typed.
|
|
99
|
+
*/
|
|
100
|
+
const storeFromModulesYaml = (root) => ioOrNone(root, () => readFile(join(root, "node_modules", ".modules.yaml"), "utf8")).pipe(Effect.map((text) => {
|
|
101
|
+
if (Option.isNone(text)) return [];
|
|
102
|
+
const parsed = Yaml.parseResult(text.value);
|
|
103
|
+
if (Result.isFailure(parsed)) return [];
|
|
104
|
+
const document = parsed.success;
|
|
105
|
+
return Predicate.isObject(document) && typeof document.storeDir === "string" && document.storeDir.length > 0 ? [document.storeDir] : [];
|
|
106
|
+
}));
|
|
107
|
+
/**
|
|
108
|
+
* Rung 2 of store discovery: the realpath of every `.pnpm-config` entry,
|
|
109
|
+
* each walked up to its `links` ancestor. Scoped entries (`@scope/pkg`) are
|
|
110
|
+
* one level deeper.
|
|
111
|
+
*/
|
|
112
|
+
const storesFromLinks = (root) => Effect.gen(function* () {
|
|
113
|
+
const base = pnpmConfigDir(root);
|
|
114
|
+
const candidates = [];
|
|
115
|
+
for (const entry of yield* entriesOf(root, base)) if (entry.startsWith("@")) for (const inner of yield* entriesOf(root, join(base, entry))) candidates.push(`${entry}/${inner}`);
|
|
116
|
+
else candidates.push(entry);
|
|
117
|
+
const stores = [];
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
const resolved = yield* ioOrNone(root, () => realpath(join(base, candidate)));
|
|
120
|
+
if (Option.isNone(resolved)) continue;
|
|
121
|
+
const store = storeOfLinkedPath(resolved.value);
|
|
122
|
+
if (Option.isSome(store)) stores.push(store.value);
|
|
123
|
+
}
|
|
124
|
+
return stores;
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* Rung 3 of store discovery: the conventional store roots — `$PNPM_HOME/store`,
|
|
128
|
+
* `$XDG_DATA_HOME/pnpm/store`, `%LOCALAPPDATA%\pnpm\store` on Windows, then
|
|
129
|
+
* the platform default from `@effected/npm`'s cache table — each expanded to
|
|
130
|
+
* its `v*` subdirectories (a store root holds one directory per store
|
|
131
|
+
* version, `v11` today).
|
|
132
|
+
*/
|
|
133
|
+
const storesFromEnvironment = (root) => Effect.gen(function* () {
|
|
134
|
+
const env = process.env;
|
|
135
|
+
const platform = process.platform;
|
|
136
|
+
const roots = [];
|
|
137
|
+
if (env.PNPM_HOME) roots.push(join(env.PNPM_HOME, "store"));
|
|
138
|
+
if (env.XDG_DATA_HOME) roots.push(join(env.XDG_DATA_HOME, "pnpm", "store"));
|
|
139
|
+
if (platform === "win32" && env.LOCALAPPDATA) roots.push(join(env.LOCALAPPDATA, "pnpm", "store"));
|
|
140
|
+
const home = homedir();
|
|
141
|
+
roots.push(PackageManagerCache.defaultDirectory("pnpm", {
|
|
142
|
+
platform,
|
|
143
|
+
home
|
|
144
|
+
}));
|
|
145
|
+
if (platform !== "win32") roots.push(join(home, ".local", "share", "pnpm", "store"));
|
|
146
|
+
const stores = [];
|
|
147
|
+
for (const storeRoot of roots) {
|
|
148
|
+
const versions = (yield* entriesOf(root, storeRoot)).filter((entry) => /^v\d+$/.test(entry)).sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)));
|
|
149
|
+
for (const version of versions) stores.push(join(storeRoot, version));
|
|
150
|
+
}
|
|
151
|
+
return stores;
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* Every store directory the ladder searches, in discovery order,
|
|
155
|
+
* deduplicated. IO failures during discovery are attributed to the workspace
|
|
156
|
+
* root, since discovery serves every dependency of one `resolvePnpmfiles`
|
|
157
|
+
* call rather than any single one.
|
|
158
|
+
*/
|
|
159
|
+
const discoverStores = (root) => Effect.gen(function* () {
|
|
160
|
+
const [fromYaml, fromLinks, fromEnvironment] = yield* Effect.all([
|
|
161
|
+
storeFromModulesYaml(root),
|
|
162
|
+
storesFromLinks(root),
|
|
163
|
+
storesFromEnvironment(root)
|
|
164
|
+
]);
|
|
165
|
+
const canonical = [];
|
|
166
|
+
for (const store of [
|
|
167
|
+
...fromYaml,
|
|
168
|
+
...fromLinks,
|
|
169
|
+
...fromEnvironment
|
|
170
|
+
]) {
|
|
171
|
+
const resolved = yield* ioOrNone(root, () => realpath(store));
|
|
172
|
+
if (Option.isSome(resolved) && !canonical.includes(resolved.value)) canonical.push(resolved.value);
|
|
173
|
+
}
|
|
174
|
+
return canonical;
|
|
175
|
+
});
|
|
176
|
+
/**
|
|
177
|
+
* The `<store>/links/<name>/<declared>/<hash>/node_modules/<name>` directories
|
|
178
|
+
* whose manifest carries exactly `declared`, from the FIRST store (in
|
|
179
|
+
* discovery order) that holds any — `[]` when none does.
|
|
180
|
+
*
|
|
181
|
+
* @remarks
|
|
182
|
+
* The `<hash>` segment is pnpm's, and it is NOT derivable from the declared
|
|
183
|
+
* integrity (sha256/sha512 of the integrity string, its decoded bytes, the
|
|
184
|
+
* `name@version` dep path and the tarball URL were all tried and none match),
|
|
185
|
+
* and the store keeps no integrity metadata beside the entry — so the
|
|
186
|
+
* manifest version is the only identity this rung can check.
|
|
187
|
+
*
|
|
188
|
+
* Two rules follow. The decision is scoped to ONE store: discovery order
|
|
189
|
+
* already encodes authority (`.modules.yaml` names the store this workspace
|
|
190
|
+
* installed from, the environment rungs are guesses), so a version held by
|
|
191
|
+
* several distinct stores — `store/v10` beside `store/v11`, a workspace store
|
|
192
|
+
* beside the platform default — resolves from the first, exactly as the rung
|
|
193
|
+
* order intends. Within that store every match is returned rather than the
|
|
194
|
+
* first taken, because the CALLER must fail closed when more than one hash
|
|
195
|
+
* directory carries the version: two honest copies of one version (a
|
|
196
|
+
* same-version re-publish, a private mirror, a hand-populated store) cannot be
|
|
197
|
+
* told apart here, and importing whichever `readdir` listed first would
|
|
198
|
+
* execute code the ref's own integrity pin was written to exclude. The stores
|
|
199
|
+
* are already deduplicated by realpath, so two matches here are two entries,
|
|
200
|
+
* never one entry reached by two spellings.
|
|
201
|
+
*/
|
|
202
|
+
const findInStores = (name, declared, stores) => Effect.gen(function* () {
|
|
203
|
+
for (const store of stores) {
|
|
204
|
+
const versionDir = join(store, "links", name, declared);
|
|
205
|
+
const matches = [];
|
|
206
|
+
for (const hash of yield* entriesOf(name, versionDir)) {
|
|
207
|
+
const dir = join(versionDir, hash, "node_modules", name);
|
|
208
|
+
const version = yield* manifestVersion(name, dir);
|
|
209
|
+
if (Option.isSome(version) && version.value === declared) matches.push(dir);
|
|
210
|
+
}
|
|
211
|
+
if (matches.length > 0) return {
|
|
212
|
+
store,
|
|
213
|
+
matches
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
store: "",
|
|
218
|
+
matches: []
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
/** The fail-closed message for a version ONE store holds more than once: which store, which copies, how to disambiguate. */
|
|
222
|
+
const ambiguousMessage = (name, declared, store, matches) => `config dependency ${name}@${declared} is ambiguous: the pnpm store at ${store} holds ${matches.length} copies of that version (${matches.join(", ")}) and the store records no integrity to tell them apart, so none is replayed. Remove the stale copies, or install ${name}@${declared} in this workspace so node_modules/.pnpm-config answers instead.`;
|
|
223
|
+
/** The fail-closed message: what was declared, what is installed, where we looked, and how to fix it. */
|
|
224
|
+
const notInstalledMessage = (name, declared, installed, stores) => {
|
|
225
|
+
return `config dependency ${name}@${declared} is not installed: node_modules/.pnpm-config/${name} holds ${Option.match(installed, {
|
|
226
|
+
onNone: () => "nothing",
|
|
227
|
+
onSome: (version) => version === "" ? "a package with no version" : `version ${version}`
|
|
228
|
+
})}, and no store copy of ${name}@${declared} was found (${stores.length === 0 ? "no pnpm store directory could be located" : `searched ${stores.join(", ")}`}). Run \`pnpm add --config ${name}@${declared}\` in a throwaway workspace to populate the store, then retry.`;
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* The first pnpmfile candidate present in `dir`, from ONE directory listing.
|
|
232
|
+
* `None` when the dependency ships none. Presence is settled by the listing
|
|
233
|
+
* alone: a listed-but-unreadable file (`EACCES`, say) is returned and fails
|
|
234
|
+
* typed at `import()` time — it never reads as "this dependency ships no
|
|
235
|
+
* hook".
|
|
236
|
+
*/
|
|
237
|
+
const pnpmfileIn = (name, dir) => entriesOf(name, dir).pipe(Effect.map((entries) => Option.map(Array.findFirst(PNPMFILE_CANDIDATES, (candidate) => entries.includes(candidate)), (filename) => join(dir, filename))));
|
|
238
|
+
/**
|
|
239
|
+
* Resolve one declared config dependency to the directory holding its
|
|
240
|
+
* declared version: `.pnpm-config` when it holds exactly that version, else
|
|
241
|
+
* the store, else a typed fail-closed error. `stores` is the memoized
|
|
242
|
+
* discovery, run at most once per {@link resolvePnpmfiles} call and only on
|
|
243
|
+
* the first `.pnpm-config` miss.
|
|
244
|
+
*/
|
|
245
|
+
const resolveDirectory = (root, name, declared, stores) => Effect.gen(function* () {
|
|
246
|
+
const installedDir = join(pnpmConfigDir(root), name);
|
|
247
|
+
const installed = yield* manifestVersion(name, installedDir);
|
|
248
|
+
if (Option.isSome(installed) && installed.value === declared) return {
|
|
249
|
+
dir: installedDir,
|
|
250
|
+
source: "installed"
|
|
251
|
+
};
|
|
252
|
+
const searched = yield* stores;
|
|
253
|
+
const { store, matches } = yield* findInStores(name, declared, searched);
|
|
254
|
+
const [only, ...rest] = matches;
|
|
255
|
+
if (only !== void 0 && rest.length === 0) return {
|
|
256
|
+
dir: only,
|
|
257
|
+
source: "store"
|
|
258
|
+
};
|
|
259
|
+
if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches))));
|
|
260
|
+
return yield* Effect.fail(hooksError(name, new Error(notInstalledMessage(name, declared, installed, searched))));
|
|
261
|
+
});
|
|
262
|
+
/**
|
|
263
|
+
* Resolve every declared config dependency at its declared version, in
|
|
264
|
+
* declaration order. A dependency that ships no pnpmfile is still returned
|
|
265
|
+
* (resolved, `path` undefined — the one legitimate skip at replay time); a
|
|
266
|
+
* `..` segment in a name, an unresolvable declared version, or any
|
|
267
|
+
* non-absent IO failure fails typed as a `hooks`-source
|
|
268
|
+
* `CatalogAssemblyError`.
|
|
269
|
+
*/
|
|
270
|
+
const resolvePnpmfiles = (root, configDependencies) => Effect.gen(function* () {
|
|
271
|
+
const entries = yield* declaredEntries(configDependencies);
|
|
272
|
+
const [discoverOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(discoverStores(root), Duration.infinity);
|
|
273
|
+
const stores = Effect.onExit(discoverOnce, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate);
|
|
274
|
+
return yield* Effect.forEach(entries, ({ name, version }) => Effect.gen(function* () {
|
|
275
|
+
const { dir, source } = yield* resolveDirectory(root, name, version, stores);
|
|
276
|
+
const pnpmfile = yield* pnpmfileIn(name, dir);
|
|
277
|
+
return {
|
|
278
|
+
name,
|
|
279
|
+
version,
|
|
280
|
+
source,
|
|
281
|
+
path: Option.getOrUndefined(pnpmfile)
|
|
282
|
+
};
|
|
283
|
+
}), { concurrency: "unbounded" });
|
|
284
|
+
});
|
|
285
|
+
/**
|
|
286
|
+
* The `layerFrom` counterpart of {@link resolvePnpmfiles}: look each declared
|
|
287
|
+
* `(name, version)` up in a caller-supplied `"<name>@<version>" → path` map.
|
|
288
|
+
* Same order, same `..` refusal, same fail-closed shape for a missing entry
|
|
289
|
+
* — no filesystem is consulted, so it is safe under any `FileSystem` layer.
|
|
290
|
+
*/
|
|
291
|
+
const lookupPnpmfiles = (entries, configDependencies) => Effect.flatMap(declaredEntries(configDependencies), (declared) => Effect.forEach(declared, ({ name, version }) => {
|
|
292
|
+
const key = `${name}@${version}`;
|
|
293
|
+
const path = entries[key];
|
|
294
|
+
if (path === void 0) {
|
|
295
|
+
const known = Object.keys(entries);
|
|
296
|
+
return Effect.fail(hooksError(name, /* @__PURE__ */ new Error(`config dependency ${key} has no entry in the supplied pnpmfile map (known: ${known.length === 0 ? "none" : known.join(", ")})`)));
|
|
297
|
+
}
|
|
298
|
+
return Effect.succeed({
|
|
299
|
+
name,
|
|
300
|
+
version,
|
|
301
|
+
source: "supplied",
|
|
302
|
+
path
|
|
303
|
+
});
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
//#endregion
|
|
307
|
+
export { lookupPnpmfiles, resolvePnpmfiles };
|
package/internal/patterns.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Effect, FileSystem, Path } from "effect";
|
|
|
2
2
|
import { Yaml } from "@effected/yaml";
|
|
3
3
|
|
|
4
4
|
//#region src/internal/patterns.ts
|
|
5
|
+
/** The string entries of `value` when it is an array, else `undefined`. */
|
|
5
6
|
const stringsOf = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
|
|
6
7
|
/** The `packages:` list of a `pnpm-workspace.yaml` document. Total on a parsed document. */
|
|
7
8
|
const pnpmPatternsOf = (document) => {
|
|
@@ -59,4 +60,4 @@ const readPatterns = (root) => Effect.gen(function* () {
|
|
|
59
60
|
});
|
|
60
61
|
|
|
61
62
|
//#endregion
|
|
62
|
-
export { manifestPatternsOf, pnpmPatternsOf, readPatterns };
|
|
63
|
+
export { manifestPatternsOf, pnpmPatternsOf, readPatterns, stringsOf };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ReleaseAgeGate } from "@effected/npm";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/peerPatterns.ts
|
|
4
|
+
/** One pattern (negation stripped) as a predicate, on the shared single-pattern grammar. */
|
|
5
|
+
const matcherFromPattern = (pattern) => (name) => ReleaseAgeGate.matchesExclude(name, [pattern]);
|
|
6
|
+
const isNegation = (pattern) => pattern.startsWith("!");
|
|
7
|
+
/**
|
|
8
|
+
* Compile one `ignoreMissing` / `allowAny` list into a predicate over peer
|
|
9
|
+
* names, with `@pnpm/matcher`'s composition rules (see the module header).
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
const peerNameMatcher = (patterns) => {
|
|
14
|
+
if (patterns.length === 0) return () => false;
|
|
15
|
+
const compiled = patterns.map((pattern) => isNegation(pattern) ? {
|
|
16
|
+
negation: true,
|
|
17
|
+
match: matcherFromPattern(pattern.slice(1))
|
|
18
|
+
} : {
|
|
19
|
+
negation: false,
|
|
20
|
+
match: matcherFromPattern(pattern)
|
|
21
|
+
});
|
|
22
|
+
const negations = compiled.filter((entry) => entry.negation);
|
|
23
|
+
if (negations.length === 0) return (name) => compiled.some((entry) => entry.match(name));
|
|
24
|
+
if (negations.length === compiled.length) return (name) => !negations.some((entry) => entry.match(name));
|
|
25
|
+
return (name) => {
|
|
26
|
+
let matched = false;
|
|
27
|
+
for (const entry of compiled) if (entry.negation) {
|
|
28
|
+
if (entry.match(name)) matched = false;
|
|
29
|
+
} else if (!matched && entry.match(name)) matched = true;
|
|
30
|
+
return matched;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { peerNameMatcher };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { stringsOf } from "./patterns.js";
|
|
2
|
+
import { NoPeerDependencyRules } from "../ConfigDependencyHooks.js";
|
|
3
|
+
import { Predicate } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/workspaceYaml.ts
|
|
6
|
+
/** The `configDependencies` map (name → version+integrity) of a parsed pnpm-workspace document. */
|
|
7
|
+
const configDependenciesOf = (document) => {
|
|
8
|
+
if (!Predicate.isObject(document) || !Predicate.isObject(document.configDependencies)) return {};
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [name, spec] of Object.entries(document.configDependencies)) if (typeof spec === "string") out[name] = spec;
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The `peerDependencyRules` a `pnpm-workspace.yaml` declares inline — the half
|
|
15
|
+
* `pnpm:export` materializes into the file, as opposed to the half a config
|
|
16
|
+
* dependency injects at replay time.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* **Tolerant, unlike the catalog blocks**, and the asymmetry is deliberate: a
|
|
20
|
+
* malformed catalog block must hard-fail because a silently-empty catalog makes
|
|
21
|
+
* every dependency look newly added, whereas a malformed rules block costs only
|
|
22
|
+
* suppression — the failure mode is reporting a peer pnpm would have hidden,
|
|
23
|
+
* which is visible and safe. Failing the whole assembly over it would take the
|
|
24
|
+
* catalogs down with it.
|
|
25
|
+
*
|
|
26
|
+
* Every axis is read independently, so a malformed `ignoreMissing` does not
|
|
27
|
+
* discard a well-formed `allowedVersions`.
|
|
28
|
+
*/
|
|
29
|
+
const inlinePeerDependencyRules = (document) => {
|
|
30
|
+
if (!Predicate.isObject(document) || !Predicate.isObject(document.peerDependencyRules)) return NoPeerDependencyRules;
|
|
31
|
+
const block = document.peerDependencyRules;
|
|
32
|
+
const allowedVersions = {};
|
|
33
|
+
if (Predicate.isObject(block.allowedVersions)) {
|
|
34
|
+
for (const [key, value] of Object.entries(block.allowedVersions)) if (typeof value === "string") allowedVersions[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
allowedVersions,
|
|
38
|
+
ignoreMissing: stringsOf(block.ignoreMissing) ?? [],
|
|
39
|
+
allowAny: stringsOf(block.allowAny) ?? []
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { configDependenciesOf, inlinePeerDependencyRules };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,23 +46,23 @@
|
|
|
46
46
|
"./package.json": "./package.json"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@effected/commands": "^0.
|
|
50
|
-
"@effected/git": "^0.
|
|
51
|
-
"@effected/glob": "^0.
|
|
52
|
-
"@effected/jsonc": "^0.
|
|
53
|
-
"@effected/lockfiles": "^0.
|
|
54
|
-
"@effected/npm": "^0.
|
|
55
|
-
"@effected/package-json": "^0.
|
|
56
|
-
"@effected/semver": "^0.
|
|
57
|
-
"@effected/walker": "^0.
|
|
58
|
-
"@effected/yaml": "^0.
|
|
49
|
+
"@effected/commands": "^0.8.0",
|
|
50
|
+
"@effected/git": "^0.16.0",
|
|
51
|
+
"@effected/glob": "^0.7.0",
|
|
52
|
+
"@effected/jsonc": "^0.12.0",
|
|
53
|
+
"@effected/lockfiles": "^0.10.0",
|
|
54
|
+
"@effected/npm": "^0.15.0",
|
|
55
|
+
"@effected/package-json": "^0.16.0",
|
|
56
|
+
"@effected/semver": "^0.8.0",
|
|
57
|
+
"@effected/walker": "^0.10.0",
|
|
58
|
+
"@effected/yaml": "^0.16.0",
|
|
59
59
|
"@pnpm/catalogs.config": "^1100.0.5",
|
|
60
60
|
"@pnpm/catalogs.protocol-parser": "^1100.0.0",
|
|
61
61
|
"@pnpm/catalogs.resolver": "^1100.0.0",
|
|
62
62
|
"@pnpm/catalogs.types": "^1100.0.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"effect": "4.0.0-rc.
|
|
65
|
+
"effect": "4.0.0-rc.116"
|
|
66
66
|
},
|
|
67
67
|
"engines": {
|
|
68
68
|
"node": ">=24.11.0"
|