@effected/workspaces 0.26.0 → 0.27.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/README.md CHANGED
@@ -347,6 +347,7 @@ describe("source boundary", () => {
347
347
  | `"console"` | any reference to the global `console` |
348
348
  | `"console-stdout"` | a reference to the global `console`, except a member access to a method Node writes to stderr: `error`, `warn`, `trace`, `assert`. A bare or aliased reference (`const c = console`, `f(console)`, `console[m]`) is flagged, since it can reach `log` |
349
349
  | `{ forbidImports }` | an import equal to an entry, a subpath of one, or starting with an entry's text before a trailing `*` |
350
+ | `{ forbidTokens }` | an entry's exact text in code (never in a comment, string, template text or regex), as whole text: not inside a longer identifier |
350
351
 
351
352
  Neither console rule flags core's `Console` service. `"console-stdout"` suits a stdio server that must keep stdout for its protocol but may log to stderr.
352
353
 
@@ -365,11 +366,30 @@ assert.deepStrictEqual([...new Set(scan.waived.map((offence) => offence.file))],
365
366
 
366
367
  Both kinds of glob compile with `@effected/glob` and match the scan's relative, `/`-separated path; an uncompilable one fails the scan with `GlobPatternError`.
367
368
 
369
+ To confine a token to named files rather than forbid it everywhere, forbid it with `{ forbidTokens }` and waive that rule for those files. The `"process"` rule exempts the build-time version define everywhere; this holds it to `version.ts`, and `waived` proves the confinement still matches something:
370
+
371
+ ```ts
372
+ const TOKEN = "process.env.__PACKAGE_VERSION__";
373
+ const scan = yield* SourceBoundary.scan({
374
+ root: SRC,
375
+ rules: ["process", { forbidTokens: [TOKEN] }],
376
+ allowRules: { forbidTokens: ["version.ts"] },
377
+ });
378
+ assert.deepStrictEqual(scan.violations, []);
379
+ assert.deepStrictEqual(
380
+ scan.waived.map((offence) => offence.file),
381
+ ["version.ts"],
382
+ );
383
+ ```
384
+
385
+ `version.ts` names the file at the root; `**/version.ts` names one at any depth. One `"forbidTokens"` key covers every `{ forbidTokens }` rule in a scan, so two tokens with different homes take two scans.
386
+
368
387
  The scanner is a lexer, not a type checker. Its known limits:
369
388
 
370
389
  - There is no scope analysis, so any local binding named `process` or `console` is flagged like the global: a parameter (`(process: Handle) => process.kill()`), a variable, a label, or an unannotated class field (`process = 1`). An annotated class field (`process: T`) reads as a type member and is spared. Prefer renaming the binding; failing that, waive that one rule for the file with `allowRules` and assert `scan.waived` names exactly it.
371
390
  - `globalThis["process"]` and `const { process: p } = globalThis` are not seen.
372
391
  - A regex literal directly after a block-closing `}` reads as a division.
392
+ - A variable named `yield` or `await` in a sloppy-mode script reads as the keyword, so a `/` after it opens a regex. Module and strict code reserve both words, so a module never hits this.
373
393
  - JSX text reads as code, which is why `.tsx` and `.jsx` are not scanned by default.
374
394
  - `forbidImports: ["node:*"]` matches only the `node:` spelling, not a bare `"fs"`. To forbid both, spread Node's list in the test file: `{ forbidImports: ["node:*", ...builtinModules] }`, with `builtinModules` from `node:module`. That also forbids npm packages named like a built-in (`events`, `buffer`).
375
395
 
@@ -377,7 +397,7 @@ The scanner is a lexer, not a type checker. Its known limits:
377
397
 
378
398
  `PackedInstall.run` packs a carrier and every workspace package it needs, then installs the carrier into a fresh project outside the workspace under each package manager that answers `--version`, and checks every expected bin is linked and executable. It carries the traps each manager sets: the parent run's `npm_*` and `pnpm_config_*` context is scrubbed, pnpm's overrides go in `pnpm-workspace.yaml`, Yarn Berry gets the `node-modules` linker, `packageManager` is pinned to the probed version, and lifecycle scripts are skipped. By default it packs each package's built `dist/prod/npm/pkg`, the artifact a release publishes; build first.
379
399
 
380
- It does not run the bins; the test does, inside the same scope, because the scratch directory is removed when the scope closes. For an MCP server bin, `McpProbe` from `@effected/mcp/testing` is the proof:
400
+ It does not run the bins; the test does, inside the same scope, because the scratch directory (`result.scratch`) is removed when the scope closes. `consumer.runBin(name, args, options?)` runs one to completion and returns `{ stdout, stderr, exitCode }`, with no direct `@effected/commands` dependency. `consumer.command(name, args?, options?)` returns the `ChildProcess` command `runBin` builds, same environment and working directory, with stdin left open, so a probe that writes to the bin can spawn it. `consumer.binProvenance(name)` names the package a `.bin` symlink resolves into. `PackedInstall.closure(carrier, options?)` names every package the run will pack before it runs, and `PackedInstall.timeoutBudget` sizes the outer timeout from those names and the run's own ceilings. For an MCP server bin, `McpProbe` from `@effected/mcp/testing` is the proof:
381
401
 
382
402
  ```ts
383
403
  // __test__/e2e/packed-install.e2e.test.ts, two levels below the workspace root
@@ -388,49 +408,78 @@ import { assert, describe, layer } from "@effect/vitest";
388
408
  import { McpProbe } from "@effected/mcp/testing";
389
409
  import { Workspaces } from "@effected/workspaces";
390
410
  import { PackedInstall } from "@effected/workspaces/testing";
391
- import { Effect, Layer } from "effect";
392
- import { ChildProcess } from "effect/unstable/process";
411
+ import { Duration, Effect, Layer } from "effect";
393
412
 
394
413
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
395
414
  const Live = Workspaces.layer({ cwd: ROOT }).pipe(Layer.provideMerge(NodeServices.layer));
396
415
 
416
+ const MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
417
+ const INSTALL_TIMEOUT = "2 minutes";
418
+ const PACK_TIMEOUT = "1 minute";
419
+ const BIN_TIMEOUT = "30 seconds";
420
+ // The packages the run will pack, known before the test is declared.
421
+ const PACKAGES = await Effect.runPromise(PackedInstall.closure("my-tool").pipe(Effect.provide(Live)));
422
+ // Every ceiling the run sets, plus the two bin runs per consumer. The vitest
423
+ // timeout sits above it, so a named PackedInstallError fires before either guard.
424
+ const BUDGET = PackedInstall.timeoutBudget({
425
+ managers: MANAGERS,
426
+ installTimeout: INSTALL_TIMEOUT,
427
+ packTimeout: PACK_TIMEOUT,
428
+ packages: PACKAGES,
429
+ perConsumer: "1 minute",
430
+ });
431
+
397
432
  describe("packed install", () => {
398
433
  // The real clock: installs and the probe are real processes.
399
434
  layer(Live, { excludeTestServices: true })((it) => {
400
435
  it.effect(
401
- "my-tool's MCP bin boots from a packed install under every available manager",
436
+ "my-tool's bins work from a packed install under every available manager",
402
437
  () =>
403
438
  Effect.gen(function* () {
404
439
  const result = yield* PackedInstall.run({
405
440
  carrier: "my-tool",
406
441
  closure: "auto",
407
- managers: ["npm", "pnpm", "yarn", "bun"],
408
- bins: ["my-tool-mcp"],
442
+ managers: MANAGERS,
443
+ bins: ["my-tool", "my-tool-mcp"],
409
444
  env: process.env,
410
- installTimeout: "2 minutes",
445
+ installTimeout: INSTALL_TIMEOUT,
446
+ packTimeout: PACK_TIMEOUT,
411
447
  });
448
+ assert.deepStrictEqual(Object.keys(result.tarballs), [...PACKAGES]);
412
449
  assert.isAbove(result.consumers.length, 0, `nothing installed; unavailable: ${result.unavailable.join(", ")}`);
413
- const env = PackedInstall.scrubEnv(process.env);
450
+ // The tool's data lives inside the scratch root and is removed with it.
451
+ const env = { XDG_DATA_HOME: `${result.scratch}/xdg` };
414
452
  for (const consumer of result.consumers) {
415
- const bin = ChildProcess.make(consumer.binPath("my-tool-mcp"), [], {
416
- cwd: consumer.directory,
417
- env,
418
- extendEnv: false,
419
- });
420
- const { response, stderr, exitCode } = yield* McpProbe.initialize(bin).pipe(Effect.timeout("30 seconds"));
453
+ const version = yield* consumer.runBin("my-tool", ["--version"], { env, timeout: BIN_TIMEOUT });
454
+ assert.strictEqual(version.exitCode, 0, `${consumer.manager}: ${version.stderr}`);
455
+ // Only the carrier declares my-tool (the run fails BinConflict otherwise, unless
456
+ // allowSharedBins), so the .bin link is the carrier's. pnpm's shims are not read: undefined.
457
+ const provenance = yield* consumer.binProvenance("my-tool");
458
+ if (consumer.manager !== "pnpm") assert.strictEqual(provenance?.package, "my-tool", consumer.manager);
459
+ const probe = McpProbe.initialize(consumer.command("my-tool-mcp", [], { env }));
460
+ const { response, stderr, exitCode } = yield* probe.pipe(Effect.timeout(BIN_TIMEOUT));
421
461
  assert.isUndefined(response.error, `${consumer.manager}: initialize was refused`);
422
462
  assert.strictEqual(stderr, "", `${consumer.manager}: stderr`);
423
463
  assert.strictEqual(exitCode, 0, `${consumer.manager}: exit code`);
424
464
  }
425
- // The installs run one after another: the guard covers 4 managers x installTimeout, plus pack and probes.
426
- }).pipe(Effect.timeout("12 minutes")),
427
- 780_000,
465
+ }).pipe(Effect.timeout(BUDGET)),
466
+ Duration.toMillis(BUDGET) + 60_000,
428
467
  );
429
468
  });
430
469
  });
431
470
  ```
432
471
 
433
- Pass `process.env` in: nothing in the subpath reads `process` itself. Run the bins under `PackedInstall.scrubEnv(process.env)`, the same environment the installs ran under. A requested manager that is not installed lands in `result.unavailable`; pass `require: "all"` to make that a failure instead. Declare in `consumerDependencies` every package the consumer's own code imports besides the carrier: pnpm links only declared dependencies at a project's top level. An entry that names a packed package is written as its `file:` tarball whatever spec you pass, so any range will do; npm fails `EOVERRIDE` when a direct spec differs from its override. The installs run one after another, so an outer `Effect.timeout` must be at least the number of managers times `installTimeout` (four minutes by default), plus the pack and the probes; a tighter guard fires first, as a `TimeoutError` that names no manager. `PackedInstall` is POSIX-only and fails `UnsupportedPlatform` elsewhere.
472
+ Pass `process.env` in: nothing in the subpath reads `process` itself. `runBin` and `command` run under the environment the install ran under, with `options.env` layered over it after the parent manager's context is stripped, so an explicit entry such as `CI: "true"` wins and a key set to `undefined` removes a variable; a non-zero exit is a result, and a bin that cannot spawn or outlives its ceiling (one minute by default) fails `BinFailed`. `PackedInstall.scrubEnv(...)` returns the same scrubbed environment for any other spawn. A requested manager that is not installed lands in `result.unavailable`; pass `require: "all"` to make that a failure instead. Declare in `consumerDependencies` every package the consumer's own code imports besides the carrier: pnpm links only declared dependencies at a project's top level. An entry that names a packed package is written as its `file:` tarball whatever spec you pass, so any range will do; npm fails `EOVERRIDE` when a direct spec differs from its override.
473
+
474
+ By default only the carrier may declare its bins. If any other packed package declares one of the carrier's bin names, the run fails `BinConflict` before installing: under npm, bun and Yarn's flat layouts either package can take the `.bin` slot (npm and bun were observed to link the package whose name sorts first; Yarn 1 and 4 kept the carrier's, the consumer's direct dependency), so the bin that runs may carry no carrier identity, and the bin check and every bin run could pass on the wrong package. The check reads the `bin` field of the packed packages only (the carrier, the closure and `overrides`): it does not read `directories.bin`, and a registry dependency declaring the same name goes undetected.
475
+
476
+ Sharing bin names is a supported choice for a carrier whose front ends are also installed on their own: pass `allowSharedBins: true`. The expected bins are still verified present, but `runBin` may then run a front end's bin. `consumer.runCarrierBin(name, args?, options?)` runs the carrier's own bin whichever package took the slot: it resolves `name` through the carrier's installed `package.json` `bin` map and runs that file with `node`, under the same environment as `runBin`. Running through `node` assumes a Node script, drops any shebang flags, and bypasses the executable bit, so it proves the carrier's shim runs, not that it is executable. `consumer.carrierCommand(name, args?, options?)` returns that command, stdin open, for `McpProbe`.
477
+
478
+ When a closure member needs a dependency the registry does not have yet, for example a sibling checkout's unreleased build linked through a dogfood `file:` override, pass it in `overrides`: package name to a publish-ready package directory, which is `npm pack`ed, or to a `.tgz`, which is used as it is. A relative path resolves against the workspace root. Every consumer steers that package to its tarball through the same override field as the closure, so the carrier's transitive references install it whatever range they ask for, and it joins `result.tarballs`. `workspaceOverrides: true` also takes every `"<name>": "file:<path>"` entry of the root `pnpm-workspace.yaml`'s `overrides:`, which is the link shape a dogfood loop writes; an explicit `overrides` entry wins over one read there. An override naming the carrier or a closure member, a path that is neither a package directory nor a `.tgz`, or a tarball whose manifest has another name fails `InvalidOverride`.
479
+
480
+ The installs run one after another. `timeoutBudget` adds each manager's `--version` probe (30 seconds), install (`installTimeout`, four minutes by default) and `perConsumer` (one minute by default, one `runBin` at its default ceiling; add a minute per further bin run, or pass `"0 seconds"` for a test that only installs), each package's pack (`packTimeout`, two minutes by default) and manifest read (30 seconds), 30 seconds for the untimed steps, and one minute for cleanup (removing the scratch root when the scope closes, and killing a child after its ceiling interrupts it); a tighter outer guard fires first, as a `TimeoutError` that names no manager. `packages` is a count or the names `PackedInstall.closure` returns for the run's options: pass the run's own options object as the second argument when it sets `closure`, `overrides` or `workspaceOverrides`, and the names equal `Object.keys(result.tarballs)` in order. `PackedInstall.timeoutBudgetFor(runOptions, { perConsumer? })` does both in one Effect, reading `managers`, `installTimeout` and `packTimeout` from the same object. A vitest test's timeout is fixed when it is declared, so await either at module evaluation, as the example above does.
481
+
482
+ `binProvenance` reads the `.bin` entry as a symlink, which npm, bun and Yarn (under the `node-modules` linker the run configures) write, realpaths it, and walks up to the nearest `package.json` with a string `name`, inside the (realpath'd) consumer directory. `undefined` means only that the entry exists and is not a symlink, which is a pnpm shim. An entry that does not exist, or a link to nothing, fails `MissingBin`; a link into no named package inside the consumer fails `UnownedBin`; any other read failure fails `Io`. `PackedInstall` is POSIX-only and fails `UnsupportedPlatform` elsewhere.
434
483
 
435
484
  ## Error handling
436
485
 
package/SourceBoundary.js CHANGED
@@ -22,9 +22,10 @@ var Offence = class extends Schema.Class("Offence")({
22
22
  "stdout-write",
23
23
  "console",
24
24
  "console-stdout",
25
- "forbidImports"
25
+ "forbidImports",
26
+ "forbidTokens"
26
27
  ]),
27
- /** What matched: the identifier, the call, or the import specifier. */
28
+ /** What matched: the identifier, the call, the import specifier, or the token. */
28
29
  detail: Schema.String
29
30
  }) {
30
31
  /** `file:line:column rule detail`, the form an assertion message reads best in. */
@@ -112,6 +113,23 @@ const memberAfter = (code, at, name) => {
112
113
  while (isIdentifierChar(code[end])) end++;
113
114
  return end > j ? code.slice(j, end) : void 0;
114
115
  };
116
+ /**
117
+ * Every offset where `token` appears in `code` as whole text: a token that
118
+ * starts with an identifier character must not continue one (nor follow `#`),
119
+ * and one that ends with an identifier character must not run into another.
120
+ */
121
+ const tokenOccurrences = (code, token) => {
122
+ if (token.length === 0) return [];
123
+ const guardStart = isIdentifierChar(token[0]);
124
+ const guardEnd = isIdentifierChar(token[token.length - 1]);
125
+ const found = [];
126
+ for (let at = code.indexOf(token); at !== -1; at = code.indexOf(token, at + 1)) {
127
+ if (guardStart && (isIdentifierChar(code[at - 1]) || code[at - 1] === "#")) continue;
128
+ if (guardEnd && isIdentifierChar(code[at + token.length])) continue;
129
+ found.push(at);
130
+ }
131
+ return found;
132
+ };
115
133
  const forbids = (entry, specifier) => entry.endsWith("*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry || specifier.startsWith(`${entry}/`);
116
134
  const isNodeModule = (specifier, module) => [module, `node:${module}`].some((name) => specifier === name || specifier.startsWith(`${name}/`));
117
135
  const FIXTURES = [
@@ -348,6 +366,48 @@ const FIXTURES = [
348
366
  source: "// import { NodeServices } from \"@effect/platform-node\";",
349
367
  rule: { forbidImports: ["@effect/platform*"] },
350
368
  flagged: false
369
+ },
370
+ {
371
+ name: "forbidTokens: the build-time version constant",
372
+ source: "export const version = process.env.__PACKAGE_VERSION__;",
373
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
374
+ flagged: true
375
+ },
376
+ {
377
+ name: "forbidTokens: the token reached through globalThis",
378
+ source: "const version = globalThis.process.env.__PACKAGE_VERSION__;",
379
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
380
+ flagged: true
381
+ },
382
+ {
383
+ name: "forbidTokens: a string and a comment",
384
+ source: "const s = \"process.env.__PACKAGE_VERSION__\"; // process.env.__PACKAGE_VERSION__",
385
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
386
+ flagged: false
387
+ },
388
+ {
389
+ name: "forbidTokens: a longer identifier",
390
+ source: "const v = process.env.__PACKAGE_VERSION__X;",
391
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
392
+ flagged: false
393
+ },
394
+ {
395
+ name: "forbidTokens: the tail of a longer identifier",
396
+ source: "const v = xprocess.env.__PACKAGE_VERSION__;",
397
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
398
+ flagged: false
399
+ },
400
+ {
401
+ name: "forbidTokens: after a variable named of, divided",
402
+ source: "const of = 8; const x = of / 2; const v = process.env.__PACKAGE_VERSION__; const y = 8 / 2;",
403
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
404
+ flagged: true
405
+ },
406
+ {
407
+ name: "forbidTokens: a private field",
408
+ source: "const v = this.#process.env.__PACKAGE_VERSION__;",
409
+ rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
410
+ flagged: false
351
411
  }
352
412
  ];
353
413
  /**
@@ -383,6 +443,10 @@ const FIXTURES = [
383
443
  *
384
444
  * - JSX text reads as code;
385
445
  *
446
+ * - a variable named `yield` or `await` in a sloppy-mode script reads as the
447
+ * keyword, so a `/` after it opens a regex (module and strict code reserve
448
+ * both words);
449
+ *
386
450
  * - `forbidImports: ["node:*"]` does not catch a bare built-in such as
387
451
  * `"fs"` (see {@link BoundaryRule}).
388
452
  *
@@ -412,7 +476,22 @@ var SourceBoundary = class SourceBoundary {
412
476
  static importSpecifiers = (text) => specifierLiterals(lex(text)).map((literal) => literal.value);
413
477
  /** Whether `text` imports the Node built-in `module`, in either its `node:` or bare form. */
414
478
  static importsNode = (text, module) => SourceBoundary.importSpecifiers(text).some((specifier) => isNodeModule(specifier, module));
415
- /** Every place `text` breaks one of `rules`, in source order, attributed to `file`. */
479
+ /**
480
+ * Every place `text` breaks one of `rules`, in source order, attributed to `file`.
481
+ *
482
+ * @remarks
483
+ * A `{ forbidTokens }` entry matches its exact text in code only: comments,
484
+ * strings, template text and regex bodies are blanked first, so a token that
485
+ * itself contains a string literal never matches, and whitespace inside a
486
+ * token must match byte for byte. A token that starts with an identifier
487
+ * character does not match inside a longer identifier or after `#`, and one
488
+ * that ends with one does not match when an identifier character follows.
489
+ * A member access is still a match: `globalThis.process.env.X` contains the
490
+ * token `process.env.X`. A token listed twice is reported once. A token with
491
+ * no identifier character at either end (`"=>"`, `"?."`) has no edge guard,
492
+ * so it matches inside longer punctuation, and consecutive matches of it can
493
+ * overlap.
494
+ */
416
495
  static check = (file, text, rules, options) => {
417
496
  const lexed = lex(text);
418
497
  const at = locate(text);
@@ -448,6 +527,11 @@ var SourceBoundary = class SourceBoundary {
448
527
  detail: member === void 0 ? "console" : `console.${member}`
449
528
  });
450
529
  }
530
+ else if ("forbidTokens" in rule) for (const token of new Set(rule.forbidTokens)) for (const offset of tokenOccurrences(lexed.code, token)) found.push({
531
+ offset,
532
+ rule: "forbidTokens",
533
+ detail: token
534
+ });
451
535
  else for (const literal of specifiers) if (rule.forbidImports.some((entry) => forbids(entry, literal.value))) found.push({
452
536
  offset: literal.start,
453
537
  rule: "forbidImports",
@@ -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.27.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": [