@effected/workspaces 0.26.0 → 0.28.0-next-20260927062352
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 +31 -10
- package/ConfigDependencySpec.js +170 -0
- package/PackedInstall.js +530 -51
- package/README.md +67 -18
- package/SourceBoundary.js +87 -3
- package/WorkspaceCatalogs.js +9 -4
- package/WorkspaceSnapshots.js +11 -5
- package/index.d.ts +217 -12
- package/index.js +2 -1
- package/internal/configDependencyFetch.js +146 -0
- package/internal/configDependencyResolution.js +83 -57
- package/internal/configDependencyShared.js +51 -0
- package/internal/configDependencySpecGrammar.js +15 -0
- package/internal/packedInstallPlan.js +73 -8
- package/internal/sourceText.js +32 -6
- package/package.json +4 -4
- package/testing.d.ts +484 -29
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { carries, hooksError, ioOrNone, manifestVersion, sideLabel } from "./configDependencyShared.js";
|
|
2
|
+
import { splitConfigDependencySpec } from "./configDependencySpecGrammar.js";
|
|
1
3
|
import { Array, Duration, Effect, Exit, Option, Predicate, Result } from "effect";
|
|
2
|
-
import {
|
|
4
|
+
import { PackageManagerCache } from "@effected/npm";
|
|
3
5
|
import { Yaml } from "@effected/yaml";
|
|
6
|
+
import { PnpmEnvLockfile } from "@effected/lockfiles";
|
|
4
7
|
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
5
8
|
import { homedir } from "node:os";
|
|
6
9
|
import { basename, dirname, join } from "node:path";
|
|
@@ -15,11 +18,13 @@ const PNPMFILE_CANDIDATES = [
|
|
|
15
18
|
/**
|
|
16
19
|
* The version part of a `configDependencies` spec: the text before the first
|
|
17
20
|
* `+` (`0.9.0+sha512-…` → `0.9.0`); a bare `0.9.0` is returned whole.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately lenient — it goes through the same split as the public
|
|
23
|
+
* `ConfigDependencySpec` but validates neither half. See
|
|
24
|
+
* `configDependencySpecGrammar.ts` for why replay must not adopt the strict
|
|
25
|
+
* model's rejections.
|
|
18
26
|
*/
|
|
19
|
-
const declaredVersionOf = (spec) =>
|
|
20
|
-
const plus = spec.indexOf("+");
|
|
21
|
-
return plus === -1 ? spec : spec.slice(0, plus);
|
|
22
|
-
};
|
|
27
|
+
const declaredVersionOf = (spec) => splitConfigDependencySpec(spec).version;
|
|
23
28
|
/**
|
|
24
29
|
* Whether a config-dependency `name` carries a `..` path segment. A scoped name
|
|
25
30
|
* legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
|
|
@@ -27,12 +32,6 @@ const declaredVersionOf = (spec) => {
|
|
|
27
32
|
* dynamic `import()`.
|
|
28
33
|
*/
|
|
29
34
|
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
35
|
/**
|
|
37
36
|
* The declared `(name, version)` pairs of a `configDependencies` map, in
|
|
38
37
|
* declaration order, with EVERY name validated before any path is built from
|
|
@@ -42,36 +41,12 @@ const hooksError = (path, cause) => new CatalogAssemblyError({
|
|
|
42
41
|
const declaredEntries = (configDependencies) => {
|
|
43
42
|
const entries = Object.entries(configDependencies).map(([name, spec]) => ({
|
|
44
43
|
name,
|
|
45
|
-
version: declaredVersionOf(spec)
|
|
44
|
+
version: declaredVersionOf(spec),
|
|
45
|
+
spec
|
|
46
46
|
}));
|
|
47
47
|
const traversal = entries.find((entry) => hasTraversalSegment(entry.name));
|
|
48
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
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
50
|
/** Directory entries of `dir`, or `[]` when it is absent; typed on any other failure. */
|
|
76
51
|
const entriesOf = (path, dir) => ioOrNone(path, () => readdir(dir)).pipe(Effect.map(Option.getOrElse(() => [])));
|
|
77
52
|
/** The `<root>/node_modules/.pnpm-config` directory. */
|
|
@@ -206,7 +181,7 @@ const findInStores = (name, declared, stores) => Effect.gen(function* () {
|
|
|
206
181
|
for (const hash of yield* entriesOf(name, versionDir)) {
|
|
207
182
|
const dir = join(versionDir, hash, "node_modules", name);
|
|
208
183
|
const version = yield* manifestVersion(name, dir);
|
|
209
|
-
if (
|
|
184
|
+
if (carries(version, declared)) matches.push(dir);
|
|
210
185
|
}
|
|
211
186
|
if (matches.length > 0) return {
|
|
212
187
|
store,
|
|
@@ -220,12 +195,23 @@ const findInStores = (name, declared, stores) => Effect.gen(function* () {
|
|
|
220
195
|
});
|
|
221
196
|
/** The fail-closed message for a version ONE store holds more than once: which store, which copies, how to disambiguate. */
|
|
222
197
|
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
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The fail-closed message: what was declared, by which side, what is
|
|
200
|
+
* installed, where we looked, why that happens, and how to fix it. `fetch`
|
|
201
|
+
* is the fetch rung's failure when one ran.
|
|
202
|
+
*/
|
|
203
|
+
const notInstalledMessage = (name, declared, installed, stores, side, fetch) => {
|
|
204
|
+
const holds = installed._tag === "absent" ? "nothing" : installed._tag === "unversioned" ? "a package with no version" : `version ${installed.version}`;
|
|
205
|
+
const searched = stores.length === 0 ? "no pnpm store directory could be located" : `searched ${stores.join(", ")}`;
|
|
206
|
+
return [
|
|
207
|
+
`config dependency ${name}@${declared}${side.ref === void 0 ? "" : ` (declared at ${sideLabel(side)})`} is not installed: node_modules/.pnpm-config/${name} holds ${holds}, and no store copy of ${name}@${declared} was found (${searched}).`,
|
|
208
|
+
installed._tag === "version" && installed.version !== declared ? "This side declares a different version from the installed one, as the base side of a diff across a config-dependency bump does: that version was never installed in this checkout." : "",
|
|
209
|
+
Option.match(fetch, {
|
|
210
|
+
onNone: () => "This replay layer does not fetch; ConfigDependencyHooks.layerSubprocess, which the *Subprocess Workspaces composites use, fetches the declared version, verified, into the store.",
|
|
211
|
+
onSome: (failure) => failure.reason === "fetchFailed" ? `Fetching it into the store failed: ${failure.message}` : failure.message
|
|
212
|
+
}),
|
|
213
|
+
Option.exists(fetch, (failure) => failure.reason === "integrityMismatch") ? "" : `Run \`pnpm add --config ${name}@${declared}\` in a throwaway workspace to populate the store, then retry.`
|
|
214
|
+
].filter((sentence) => sentence !== "").join(" ");
|
|
229
215
|
};
|
|
230
216
|
/**
|
|
231
217
|
* The first pnpmfile candidate present in `dir`, from ONE directory listing.
|
|
@@ -238,14 +224,26 @@ const pnpmfileIn = (name, dir) => entriesOf(name, dir).pipe(Effect.map((entries)
|
|
|
238
224
|
/**
|
|
239
225
|
* Resolve one declared config dependency to the directory holding its
|
|
240
226
|
* declared version: `.pnpm-config` when it holds exactly that version, else
|
|
241
|
-
* the store, else
|
|
242
|
-
* discovery, run at most once per
|
|
243
|
-
* the first `.pnpm-config` miss
|
|
227
|
+
* the store, else the fetch rung when one is wired, else a typed fail-closed
|
|
228
|
+
* error. `stores` is the memoized discovery, run at most once per
|
|
229
|
+
* {@link resolvePnpmfiles} call and only on the first `.pnpm-config` miss;
|
|
230
|
+
* `locks` is the declaring side's memoized lockfile decode, run at most once
|
|
231
|
+
* per call and only by the fetch rung.
|
|
232
|
+
*
|
|
233
|
+
* @remarks
|
|
234
|
+
* A fetch failure is folded into the not-installed message, so the error
|
|
235
|
+
* still says what was declared, what is installed and where the ladder
|
|
236
|
+
* looked, and it keeps the fetch rung's own `reason` (`integrityMismatch`,
|
|
237
|
+
* `integrityUnavailable`, `fetchFailed`). The `pnpm add --config`
|
|
238
|
+
* remediation is kept except for `integrityMismatch`, where the pins must be
|
|
239
|
+
* reconciled first.
|
|
244
240
|
*/
|
|
245
|
-
const resolveDirectory = (root,
|
|
241
|
+
const resolveDirectory = (root, entry, stores, locks, options) => Effect.gen(function* () {
|
|
242
|
+
const { name, version: declared } = entry;
|
|
243
|
+
const side = options.side ?? {};
|
|
246
244
|
const installedDir = join(pnpmConfigDir(root), name);
|
|
247
245
|
const installed = yield* manifestVersion(name, installedDir);
|
|
248
|
-
if (
|
|
246
|
+
if (carries(installed, declared)) return {
|
|
249
247
|
dir: installedDir,
|
|
250
248
|
source: "installed"
|
|
251
249
|
};
|
|
@@ -256,23 +254,51 @@ const resolveDirectory = (root, name, declared, stores) => Effect.gen(function*
|
|
|
256
254
|
dir: only,
|
|
257
255
|
source: "store"
|
|
258
256
|
};
|
|
259
|
-
if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches))));
|
|
260
|
-
|
|
257
|
+
if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches)), "ambiguous"));
|
|
258
|
+
const notInstalled = (fetch) => {
|
|
259
|
+
const message = notInstalledMessage(name, declared, installed, searched, side, fetch);
|
|
260
|
+
const cause = Option.getOrUndefined(fetch)?.cause;
|
|
261
|
+
return hooksError(name, cause === void 0 ? new Error(message) : new Error(message, { cause }), Option.match(fetch, {
|
|
262
|
+
onNone: () => "notInstalled",
|
|
263
|
+
onSome: (failure) => failure.reason
|
|
264
|
+
}));
|
|
265
|
+
};
|
|
266
|
+
if (options.fetch === void 0) return yield* Effect.fail(notInstalled(Option.none()));
|
|
267
|
+
return {
|
|
268
|
+
dir: yield* options.fetch({
|
|
269
|
+
name,
|
|
270
|
+
version: declared,
|
|
271
|
+
spec: entry.spec,
|
|
272
|
+
stores: searched,
|
|
273
|
+
side,
|
|
274
|
+
locks
|
|
275
|
+
}).pipe(Effect.mapError((failure) => notInstalled(Option.some(failure)))),
|
|
276
|
+
source: "fetched"
|
|
277
|
+
};
|
|
261
278
|
});
|
|
262
279
|
/**
|
|
280
|
+
* A lazy memo of `effect`, scoped to one {@link resolvePnpmfiles} call: the
|
|
281
|
+
* house success-only memo (never `Effect.cached`, which would pin an
|
|
282
|
+
* interrupt), so a failed or interrupted run is retried by the next user.
|
|
283
|
+
*/
|
|
284
|
+
const memoizeSuccess = (effect) => Effect.map(Effect.cachedInvalidateWithTTL(effect, Duration.infinity), ([once, invalidate]) => Effect.onExit(once, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate));
|
|
285
|
+
/**
|
|
263
286
|
* Resolve every declared config dependency at its declared version, in
|
|
264
287
|
* declaration order. A dependency that ships no pnpmfile is still returned
|
|
265
288
|
* (resolved, `path` undefined — the one legitimate skip at replay time); a
|
|
266
289
|
* `..` segment in a name, an unresolvable declared version, or any
|
|
267
290
|
* non-absent IO failure fails typed as a `hooks`-source
|
|
268
|
-
* `CatalogAssemblyError`.
|
|
291
|
+
* `CatalogAssemblyError`. `options.fetch`, when wired, is tried for a version
|
|
292
|
+
* found nowhere before that fails.
|
|
269
293
|
*/
|
|
270
|
-
const resolvePnpmfiles = (root, configDependencies) => Effect.gen(function* () {
|
|
294
|
+
const resolvePnpmfiles = (root, configDependencies, options = {}) => Effect.gen(function* () {
|
|
271
295
|
const entries = yield* declaredEntries(configDependencies);
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
const stores = yield* memoizeSuccess(discoverStores(root));
|
|
297
|
+
const lockfile = options.side?.lockfile;
|
|
298
|
+
const locks = lockfile === void 0 ? Effect.succeed(void 0) : yield* memoizeSuccess(PnpmEnvLockfile.configDependencies(lockfile));
|
|
299
|
+
return yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
|
|
300
|
+
const { name, version } = entry;
|
|
301
|
+
const { dir, source } = yield* resolveDirectory(root, entry, stores, locks, options);
|
|
276
302
|
const pnpmfile = yield* pnpmfileIn(name, dir);
|
|
277
303
|
return {
|
|
278
304
|
name,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Effect, Option, Predicate } from "effect";
|
|
2
|
+
import { CatalogAssemblyError } from "@effected/npm";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/configDependencyShared.ts
|
|
7
|
+
/** The typed `hooks`-source failure every rung of the ladder reports through. */
|
|
8
|
+
const hooksError = (path, cause, reason) => new CatalogAssemblyError({
|
|
9
|
+
source: "hooks",
|
|
10
|
+
path,
|
|
11
|
+
cause,
|
|
12
|
+
...reason === void 0 ? {} : { reason }
|
|
13
|
+
});
|
|
14
|
+
/** The message of a cause, for splicing into ours. */
|
|
15
|
+
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
16
|
+
/** Where the declared spec came from, for messages: `the working tree` or `ref <ref>`. */
|
|
17
|
+
const sideLabel = (side) => side.ref === void 0 ? "the working tree" : `ref ${side.ref}`;
|
|
18
|
+
/** Whether a `node:fs` rejection means "nothing there" (as opposed to a real IO failure). */
|
|
19
|
+
const isAbsent = (cause) => Predicate.isObject(cause) && (cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
|
20
|
+
/**
|
|
21
|
+
* Run a `node:fs/promises` call, mapping an absent target to `Option.none()`
|
|
22
|
+
* and ANY other rejection (`EACCES`, `EIO`, …) to a typed `hooks` error
|
|
23
|
+
* attributed to `path` — never a silent skip.
|
|
24
|
+
*/
|
|
25
|
+
const ioOrNone = (path, run) => Effect.tryPromise({
|
|
26
|
+
try: run,
|
|
27
|
+
catch: (cause) => cause
|
|
28
|
+
}).pipe(Effect.map(Option.some), Effect.catch((cause) => isAbsent(cause) ? Effect.succeed(Option.none()) : Effect.fail(hooksError(path, cause))));
|
|
29
|
+
const ABSENT = { _tag: "absent" };
|
|
30
|
+
const UNVERSIONED = { _tag: "unversioned" };
|
|
31
|
+
/** Whether a manifest carries exactly `declared`. Only a `version` state can. */
|
|
32
|
+
const carries = (manifest, declared) => manifest._tag === "version" && manifest.version === declared;
|
|
33
|
+
/**
|
|
34
|
+
* The version state of the `package.json` in `dir`: `absent` when there is
|
|
35
|
+
* no manifest, `unversioned` when it carries no non-empty string `version`,
|
|
36
|
+
* typed on any other IO failure or on unparseable JSON — a manifest that
|
|
37
|
+
* exists but cannot be read is not evidence of absence.
|
|
38
|
+
*/
|
|
39
|
+
const manifestVersion = (name, dir) => ioOrNone(name, () => readFile(join(dir, "package.json"), "utf8")).pipe(Effect.flatMap((text) => {
|
|
40
|
+
if (Option.isNone(text)) return Effect.succeed(ABSENT);
|
|
41
|
+
return Effect.try({
|
|
42
|
+
try: () => JSON.parse(text.value),
|
|
43
|
+
catch: (cause) => hooksError(name, cause)
|
|
44
|
+
}).pipe(Effect.map((parsed) => Predicate.isObject(parsed) && typeof parsed.version === "string" && parsed.version !== "" ? {
|
|
45
|
+
_tag: "version",
|
|
46
|
+
version: parsed.version
|
|
47
|
+
} : UNVERSIONED));
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { carries, hooksError, ioOrNone, manifestVersion, messageOf, sideLabel };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/internal/configDependencySpecGrammar.ts
|
|
2
|
+
/** Split a spec on its first `+`: `0.9.0+sha512-…` → `{ version: "0.9.0", integrity: "sha512-…" }`. */
|
|
3
|
+
const splitConfigDependencySpec = (spec) => {
|
|
4
|
+
const plus = spec.indexOf("+");
|
|
5
|
+
return plus === -1 ? {
|
|
6
|
+
version: spec,
|
|
7
|
+
integrity: void 0
|
|
8
|
+
} : {
|
|
9
|
+
version: spec.slice(0, plus),
|
|
10
|
+
integrity: spec.slice(plus + 1)
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { splitConfigDependencySpec };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RUNTIME_DEPENDENCY_FIELDS } from "./dependencyFields.js";
|
|
2
|
-
import { Result } from "effect";
|
|
2
|
+
import { Predicate, Result } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/internal/packedInstallPlan.ts
|
|
5
5
|
/** Variables that carry the PARENT run's context into a child package manager. */
|
|
@@ -153,22 +153,87 @@ const installArgs = (manager, version) => {
|
|
|
153
153
|
* is left alone: it resolves wherever the file exists.
|
|
154
154
|
*/
|
|
155
155
|
const UNRESOLVABLE = /^(?:workspace:|catalog:|link:|file:(?!\/))/;
|
|
156
|
-
/**
|
|
157
|
-
const
|
|
156
|
+
/** Parse a packed manifest into the facts the run checks; the failure is the parse error or a non-object. */
|
|
157
|
+
const readPackedManifest = (manifestJson) => {
|
|
158
158
|
let manifest;
|
|
159
159
|
try {
|
|
160
160
|
manifest = JSON.parse(manifestJson);
|
|
161
161
|
} catch (cause) {
|
|
162
162
|
return Result.fail(cause);
|
|
163
163
|
}
|
|
164
|
-
if (typeof manifest !== "object" || manifest === null) return Result.fail(/* @__PURE__ */ new Error("package.json is not an object"));
|
|
164
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) return Result.fail(/* @__PURE__ */ new Error("package.json is not an object"));
|
|
165
165
|
const record = manifest;
|
|
166
|
-
|
|
166
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
167
|
+
const unresolved = RUNTIME_DEPENDENCY_FIELDS.flatMap((field) => {
|
|
167
168
|
const block = record[field];
|
|
168
169
|
if (typeof block !== "object" || block === null) return [];
|
|
169
|
-
return Object.entries(block).filter(([, spec]) => typeof spec === "string" && UNRESOLVABLE.test(spec)).map(([
|
|
170
|
-
})
|
|
170
|
+
return Object.entries(block).filter(([, spec]) => typeof spec === "string" && UNRESOLVABLE.test(spec)).map(([dependency, spec]) => `${field}.${dependency}: ${String(spec)}`);
|
|
171
|
+
});
|
|
172
|
+
const bin = record.bin;
|
|
173
|
+
const bins = typeof bin === "string" ? name === void 0 ? [] : [name.replace(/^@[^/]+\//, "")] : typeof bin === "object" && bin !== null && !Array.isArray(bin) ? Object.keys(bin) : [];
|
|
174
|
+
return Result.succeed({
|
|
175
|
+
name,
|
|
176
|
+
unresolved,
|
|
177
|
+
bins
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* The file a manifest's `bin` declares for `name`: a `bin` object's entry, or
|
|
182
|
+
* a `bin` string when `name` is the unscoped package name. `undefined` when
|
|
183
|
+
* the manifest is not a JSON object or declares no such bin.
|
|
184
|
+
*/
|
|
185
|
+
const binTargetOf = (manifestJson, name) => {
|
|
186
|
+
let manifest;
|
|
187
|
+
try {
|
|
188
|
+
manifest = JSON.parse(manifestJson);
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!Predicate.isObject(manifest) || Array.isArray(manifest)) return void 0;
|
|
193
|
+
const bin = manifest.bin;
|
|
194
|
+
if (typeof bin === "string") return typeof manifest.name === "string" && manifest.name.replace(/^@[^/]+\//, "") === name ? bin : void 0;
|
|
195
|
+
if (!Predicate.isObject(bin) || Array.isArray(bin)) return void 0;
|
|
196
|
+
const target = bin[name];
|
|
197
|
+
return typeof target === "string" && Object.hasOwn(bin, name) ? target : void 0;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* The first bin the carrier declares that another packed package declares
|
|
201
|
+
* too. Under a flat layout (npm, bun, Yarn's `node-modules` linker) either
|
|
202
|
+
* package can take `node_modules/.bin/<bin>`, so a bin check or a bin run
|
|
203
|
+
* could pass on the wrong package. `PackedInstall.run` refuses it unless the
|
|
204
|
+
* caller shares bin names deliberately (`allowSharedBins`).
|
|
205
|
+
*/
|
|
206
|
+
const binConflict = (carrier, others) => {
|
|
207
|
+
for (const bin of carrier.bins) {
|
|
208
|
+
const other = others.find((pkg) => pkg.name !== carrier.name && pkg.bins.includes(bin));
|
|
209
|
+
if (other !== void 0) return {
|
|
210
|
+
bin,
|
|
211
|
+
package: other.name
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
/** A bare package name, scoped or not: an override key carrying a selector (`a>b`, `a@1`) is not one. */
|
|
216
|
+
const BARE_NAME = /^(?:@[^/@\s>]+\/)?[^/@\s>]+$/;
|
|
217
|
+
/**
|
|
218
|
+
* The `file:` entries of a parsed `pnpm-workspace.yaml`'s `overrides:` map,
|
|
219
|
+
* name to the path after `file:` (relative paths are the caller's to resolve,
|
|
220
|
+
* against the workspace root, as pnpm does). Entries that are not strings,
|
|
221
|
+
* not `file:`, or keyed by anything but a bare package name are skipped, and
|
|
222
|
+
* so is `__proto__`, which no npm package can be named. The map has no
|
|
223
|
+
* prototype, so a package named `constructor` or `prototype` is an ordinary
|
|
224
|
+
* own entry and a missing name never resolves to an inherited member.
|
|
225
|
+
*/
|
|
226
|
+
const fileOverridesOf = (document) => {
|
|
227
|
+
const out = Object.create(null);
|
|
228
|
+
if (!Predicate.isObject(document) || !Predicate.isObject(document.overrides) || Array.isArray(document.overrides)) return out;
|
|
229
|
+
for (const [name, spec] of Object.entries(document.overrides)) {
|
|
230
|
+
if (name === "__proto__") continue;
|
|
231
|
+
if (typeof spec === "string" && spec.startsWith("file:") && BARE_NAME.test(name)) out[name] = spec.slice(5);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
171
234
|
};
|
|
235
|
+
/** An `overrides` value without the `file:` prefix a caller may copy from a workspace file. */
|
|
236
|
+
const overridePath = (spec) => spec.startsWith("file:") ? spec.slice(5) : spec;
|
|
172
237
|
|
|
173
238
|
//#endregion
|
|
174
|
-
export { closureOf, consumerFiles, installArgs,
|
|
239
|
+
export { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, installArgs, overridePath, readPackedManifest, scrubEnv, versionOf };
|
package/internal/sourceText.js
CHANGED
|
@@ -19,6 +19,13 @@ const REGEX_AFTER = /* @__PURE__ */ new Set([
|
|
|
19
19
|
"yield",
|
|
20
20
|
"await"
|
|
21
21
|
]);
|
|
22
|
+
/** Declaration keywords: the word after one is a binding name, so a `for (const of of xs)` binding named `of` is not the keyword. */
|
|
23
|
+
const DECLARATIONS = /* @__PURE__ */ new Set([
|
|
24
|
+
"const",
|
|
25
|
+
"let",
|
|
26
|
+
"var",
|
|
27
|
+
"using"
|
|
28
|
+
]);
|
|
22
29
|
/** Keywords whose parenthesized condition may be followed directly by a statement, so a `/` after its `)` opens a regex. */
|
|
23
30
|
const CONTROL = /* @__PURE__ */ new Set([
|
|
24
31
|
"if",
|
|
@@ -63,26 +70,45 @@ const lex = (text) => {
|
|
|
63
70
|
while (start >= 0 && isIdentifierChar(code[start])) start--;
|
|
64
71
|
return code.slice(start + 1, end + 1).join("");
|
|
65
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Whether the code at offset `j` ends a binding a `for...of` head iterates
|
|
75
|
+
* into: a destructuring pattern, or a name that is neither a declaration
|
|
76
|
+
* keyword nor itself a keyword (`for (x in of / 2)` divides).
|
|
77
|
+
*/
|
|
78
|
+
const endsBinding = (j) => {
|
|
79
|
+
const char = code[j] ?? "";
|
|
80
|
+
if (char === "]" || char === "}") return true;
|
|
81
|
+
return isIdentifierChar(char) && !DECLARATIONS.has(wordAt(j)) && !isRegexKeyword(j);
|
|
82
|
+
};
|
|
83
|
+
/** Whether the identifier ending at code offset `j` is a keyword after which a `/` opens a regex. */
|
|
84
|
+
const isRegexKeyword = (j) => {
|
|
85
|
+
const word = wordAt(j);
|
|
86
|
+
if (!REGEX_AFTER.has(word)) return false;
|
|
87
|
+
const before = lastCode(j - word.length);
|
|
88
|
+
if (code[before] === "." && !(code[before - 1] === "." && code[before - 2] === ".")) return false;
|
|
89
|
+
if (word === "of") return parens.at(-1) === "for" && before >= 0 && endsBinding(before);
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
66
92
|
/** Whether the code at offset `j` ends an operand (a value a postfix operator or a `/` division can follow). */
|
|
67
93
|
const endsOperand = (j) => {
|
|
68
94
|
const char = code[j] ?? "";
|
|
69
|
-
return char === ")" || char === "]" || isIdentifierChar(char) && !
|
|
95
|
+
return char === ")" || char === "]" || isIdentifierChar(char) && !isRegexKeyword(j);
|
|
70
96
|
};
|
|
71
97
|
/** Whether a `/` here opens a regex, judged by the last code token. */
|
|
72
98
|
const regexAllowed = () => {
|
|
73
99
|
const j = lastCode(code.length - 1);
|
|
74
100
|
if (j < 0) return true;
|
|
75
101
|
const last = code[j] ?? "";
|
|
76
|
-
if (isIdentifierChar(last)) return
|
|
102
|
+
if (isIdentifierChar(last)) return isRegexKeyword(j);
|
|
77
103
|
if (last === "!") return !endsOperand(j - 1);
|
|
78
104
|
if ((last === "+" || last === "-") && code[j - 1] === last) return !endsOperand(lastCode(j - 2));
|
|
79
105
|
if (last === ")") return controlCloses.has(j);
|
|
80
106
|
return !"]}\"'`".includes(last);
|
|
81
107
|
};
|
|
82
|
-
/**
|
|
108
|
+
/** The if/while/for/with keyword whose condition the `(` about to be emitted opens, if any. */
|
|
83
109
|
const opensCondition = () => {
|
|
84
110
|
const j = lastCode(code.length - 1);
|
|
85
|
-
if (j < 0 || !isIdentifierChar(code[j])) return
|
|
111
|
+
if (j < 0 || !isIdentifierChar(code[j])) return void 0;
|
|
86
112
|
let word = wordAt(j);
|
|
87
113
|
let end = j - word.length;
|
|
88
114
|
if (word === "await") {
|
|
@@ -90,7 +116,7 @@ const lex = (text) => {
|
|
|
90
116
|
word = end < 0 ? "" : wordAt(end);
|
|
91
117
|
end -= word.length;
|
|
92
118
|
}
|
|
93
|
-
return CONTROL.has(word) && code[lastCode(end)] !== ".";
|
|
119
|
+
return CONTROL.has(word) && code[lastCode(end)] !== "." ? word : void 0;
|
|
94
120
|
};
|
|
95
121
|
/** Consume a regex literal at `i` if one closes on this line; `false` leaves `i` untouched. */
|
|
96
122
|
const regex = () => {
|
|
@@ -212,7 +238,7 @@ const lex = (text) => {
|
|
|
212
238
|
emit(char, true, true);
|
|
213
239
|
i++;
|
|
214
240
|
} else if (char === ")") {
|
|
215
|
-
if (parens.pop()
|
|
241
|
+
if (parens.pop() !== void 0) controlCloses.add(code.length);
|
|
216
242
|
emit(char, true, true);
|
|
217
243
|
i++;
|
|
218
244
|
} else if (char === "{") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0-next-20260927062352",
|
|
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": [
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"@effected/git": "^0.17.0",
|
|
56
56
|
"@effected/glob": "^0.8.0",
|
|
57
57
|
"@effected/jsonc": "^0.13.0",
|
|
58
|
-
"@effected/lockfiles": "^0.
|
|
59
|
-
"@effected/npm": "^0.
|
|
60
|
-
"@effected/package-json": "^0.
|
|
58
|
+
"@effected/lockfiles": "^0.12.0-next-20260927062352",
|
|
59
|
+
"@effected/npm": "^0.17.0-next-20260927062352",
|
|
60
|
+
"@effected/package-json": "^0.18.0-next-20260927062352",
|
|
61
61
|
"@effected/semver": "^0.9.0",
|
|
62
62
|
"@effected/walker": "^0.12.0",
|
|
63
63
|
"@effected/yaml": "^0.17.0",
|