@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.
@@ -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 { CatalogAssemblyError, PackageManagerCache } from "@effected/npm";
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 (Option.isSome(version) && version.value === declared) matches.push(dir);
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
- /** 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.`;
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 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.
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, name, declared, stores) => Effect.gen(function* () {
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 (Option.isSome(installed) && installed.value === declared) return {
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
- return yield* Effect.fail(hooksError(name, new Error(notInstalledMessage(name, declared, installed, searched))));
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 [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);
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
- /** Every specifier in a packed manifest's runtime maps that only the workspace could resolve (see `UNRESOLVABLE`). */
157
- const unresolvedSpecifiers = (manifestJson) => {
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
- return Result.succeed(RUNTIME_DEPENDENCY_FIELDS.flatMap((field) => {
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(([name, spec]) => `${field}.${name}: ${String(spec)}`);
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, scrubEnv, unresolvedSpecifiers, versionOf };
239
+ export { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, installArgs, overridePath, readPackedManifest, scrubEnv, versionOf };
@@ -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) && !REGEX_AFTER.has(wordAt(j));
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 REGEX_AFTER.has(wordAt(j));
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
- /** Whether the `(` about to be emitted opens an if/while/for/with condition. */
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 false;
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() === true) controlCloses.add(code.length);
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.26.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.11.0",
59
- "@effected/npm": "^0.16.0",
60
- "@effected/package-json": "^0.17.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",