@effected/workspaces 0.28.0 → 0.30.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
@@ -163,16 +163,21 @@ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyErro
163
163
  `PeerCheck.run(lockfile, options?)` reports unsatisfied peer dependencies as a pure value: no IO, no error channel, nothing in `R`, and no per-manager traversal logic. It reads `lockfile.format` once, to reject a format whose lockfile does not record peer resolution; past that gate the walk is the same for every manager. The answer comes from the resolved graph `@effected/lockfiles` normalizes, not from shelling out to a package manager's own peer command — bun has none, so the subprocess route cannot answer for every manager the rest of this package supports.
164
164
 
165
165
  ```ts
166
- import { LockfileReader, PeerCheck, WorkspaceCatalogs } from "@effected/workspaces";
166
+ import { LockfileReader, PeerCheck, WorkspaceCatalogs, WorkspaceDiscovery } from "@effected/workspaces";
167
167
  import { Effect } from "effect";
168
168
 
169
169
  const program = Effect.gen(function* () {
170
170
  const reader = yield* LockfileReader;
171
171
  const catalogs = yield* WorkspaceCatalogs;
172
+ const discovery = yield* WorkspaceDiscovery;
172
173
 
173
174
  const lockfile = yield* reader.read();
174
- // Presence of the key is the assertion — see below.
175
- const report = PeerCheck.run(lockfile, { peerDependencyRules: yield* catalogs.peerDependencyRules() });
175
+ // Presence of each key is the assertion — see below.
176
+ const report = PeerCheck.run(lockfile, {
177
+ peerDependencyRules: yield* catalogs.peerDependencyRules(),
178
+ workspacePackages: yield* discovery.listPackages(),
179
+ catalogs: yield* catalogs.set(),
180
+ });
176
181
 
177
182
  return {
178
183
  clean:
@@ -190,10 +195,20 @@ const program = Effect.gen(function* () {
190
195
 
191
196
  - `supported` is `false` for yarn, which resolves peers virtually and does not record which virtual instance satisfied which peer. The answer is unrecoverable, so it is not fabricated.
192
197
  - `unresolvedImporters` names importers that could not be joined to package instances — in practice the root importer under npm and bun, neither of which records a resolved version per importer dependency.
193
- - `unverified` says why the report is not a complete answer: `"peerRulesNotApplied"` when the suppression policy could not be applied, `"unresolvedEdge"` when an instance records an edge the model could not name. Both mean fail closed.
198
+ - `unverified` says why the report is not a complete answer, and every reason means fail closed:
199
+ - `"peerRulesNotApplied"` — the `peerDependencyRules` key was omitted, so pnpm's suppression policy was not applied.
200
+ - `"unresolvedEdge"` — an instance records an edge the model could not name, or an importer dependency resolved through `link:` whose linked manifest's peers were not judged: its target was not supplied in `workspacePackages`, or the lockfile records no workspace row to walk. A linked parent's manifest peers are never in the lockfile, though `pnpm peers check` reads them from disk.
201
+ - `"peerRangeUnresolved"` — a linked manifest declares a peer as a protocol specifier that could not be turned into a range (`catalog:` with the `catalogs` key omitted or no matching entry, `workspace:*`), and something resolved for that peer, so the comparison never ran.
202
+ - `"peerVersionUnresolved"` — a peer resolved to a non-workspace provider whose version is a protocol specifier rather than a version: a `file:` directory or tarball, directly or through a `file:` override, which the lockfile records as `file:vendor/x` with no version for a directory, and a git or remote-tarball provider, which pnpm keys by its URL (`https://codeload.github.com/…`). The comparison never runs, so the peer is neither reported nor passed as satisfied; for a `file:` directory `pnpm peers check` reports it as a `bad` row even when the directory's manifest satisfies the range. It applies to peers declared by lockfile rows and by joined manifests alike.
194
203
  - `required` is the getter for the rows a gate should act on — the non-optional ones. An unsatisfied *optional* peer is normal, so `optional` travels with the row rather than being filtered out at the source.
195
204
 
196
- `WorkspaceCatalogs.peerDependencyRules()` returns the workspace's effective merged pnpm suppression rules, which the lockfile records nowhere; without them a checker reports findings pnpm itself calls clean. **Presence of the `peerDependencyRules` option key is the assertion, not its contents.** Passing `NoPeerDependencyRules` asserts the workspace has none, so the report carries no `"peerRulesNotApplied"` — though it can still be unverified for another reason, or unsupported; omitting the key says nobody looked, and always yields `"peerRulesNotApplied"`. Only `allowedVersions` is applied — rules populating `ignoreMissing` or `allowAny` describe a policy this package does not replicate, so they fail closed through the same reason rather than being silently ignored. All three key spellings pnpm accepts are honoured: `parent@version>peer` (what `pnpm:export` writes), `parent>peer` (what a config-dependency plugin injects) and a bare `peer`, which pnpm applies to every parent that declares it.
205
+ `WorkspaceCatalogs.peerDependencyRules()` returns the workspace's effective merged pnpm suppression rules, which the lockfile records nowhere; without them a checker reports findings pnpm itself calls clean. **Presence of the `peerDependencyRules` option key is the assertion, not its contents.** Passing `NoPeerDependencyRules` asserts the workspace has none, so the report carries no `"peerRulesNotApplied"` — though it can still be unverified for another reason, or unsupported; omitting the key says nobody looked, and always yields `"peerRulesNotApplied"`. **All three axes of the supplied rules are applied**, with pnpm's measured semantics. `allowedVersions` hides a row whose resolved version a matching key's range permits, in all three key spellings pnpm accepts: `parent@version>peer` (what `pnpm:export` writes; the parent version is ignored, as pnpm ignores it), `parent>peer` (what a config-dependency plugin injects) and a bare `peer`, which pnpm applies to every parent that declares it. `ignoreMissing` hides a required peer nothing resolved for, and `allowAny` hides a peer that resolved outside its range; both are `@pnpm/matcher` patterns over the PEER name (`*` wildcard, leading `!` negates), never `parent>peer` keys, and the two never cross.
206
+
207
+ The `workspacePackages` option takes the packages `WorkspaceDiscovery` already returned, and supplying it is what turns a `link:`-resolved parent from a refusal into an answer: the matched manifest's declared peers join the walk, named from the manifest (`probe-a@1.0.0`, not the row's `packages/a@0.0.0`) and judged against the **importer's own** dependency set, which is where pnpm resolves them from. Measured one variable at a time on the probe workspace, against pnpm 12.5.1 and 12.6.0: a consumer's own `react@18.3.1` satisfies the linked parent's `^18.0.0` peer, the same version installed only by a sibling importer does not, and a consumer's own `react@17.0.2` is a `bad` row carrying `foundVersion`. A target the set does not cover — every target, when the key is omitted — keeps `"unresolvedEdge"`, so the option answers only for what it covers and says so about the rest. A covered target the walk cannot reach — one the lockfile records no workspace row for — keeps the marker too; the root importer's linked targets are walked and judged against the root's own dependencies, as pnpm does.
208
+
209
+ A `link:` target matches a supplied package by its directory or by its publish directory: pnpm links a workspace dependency into `publishConfig.directory` unless `publishConfig.linkDirectory` is `false` (it defaults to true), recording `link:../a/dist`. For such a link pnpm reads the built manifest at the target, while `PeerCheck` reads the source manifest and resolves its `catalog:` ranges through `catalogs`; the two agree when the build emits the ranges the source's specifiers resolve to. Attribution stops at a workspace package: a linked package's manifest peers are judged only for the importer that links it directly, and its own dependencies (registry or linked) only for its own importer, never for a consumer one link further out. Each `link:` edge is cleared only by the walk of the importer that records it.
210
+
211
+ The `catalogs` option takes `WorkspaceCatalogs.set()`, which already includes hook-injected catalogs. A linked manifest may declare a peer as a catalog specifier (`"typescript": "catalog:build:peers"`) rather than a range; with the set supplied it is resolved, judged, and reported by the resolved range, which is what `pnpm peers check` reports as `wantedRange`. A specifier that stays unresolvable — the key omitted, a catalog naming nothing for the peer, or another protocol such as `workspace:*` — is never passed as satisfied: when something resolved for that peer the report carries `"peerRangeUnresolved"`, and when nothing did the `found: null` row is reported anyway, since a missing provider needs no range.
197
212
 
198
213
  ## The synchronous escape hatch
199
214
 
package/SourceBoundary.js CHANGED
@@ -279,7 +279,7 @@ const FIXTURES = [
279
279
  },
280
280
  {
281
281
  name: "node:process: an unrelated specifier",
282
- source: "import { ChildProcess } from \"effect/unstable/process\";",
282
+ source: "import { ChildProcess } from \"effect/process\";",
283
283
  rule: "node:process",
284
284
  flagged: false
285
285
  },
@@ -548,7 +548,8 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
548
548
  const version = index.get(packageName);
549
549
  return version === void 0 ? Effect.fail(new DependencyResolutionError({
550
550
  specifier,
551
- cause: /* @__PURE__ */ new Error(`Workspace member "${packageName}" declares no version`)
551
+ reason: "no-version",
552
+ cause: void 0
552
553
  })) : Effect.succeed(Option.some(version));
553
554
  }));
554
555
  } };
@@ -52,10 +52,10 @@ const snapshotOf = (content, relativePath) => {
52
52
  const parsed = parseJsonObject(content.value);
53
53
  const name = parsed.name;
54
54
  if (typeof name !== "string" || name.length === 0) return Option.none();
55
- const version = typeof parsed.version === "string" ? parsed.version : "";
55
+ const version = parsed.version;
56
56
  return Option.some(PackageStateSnapshot.make({
57
57
  name,
58
- version,
58
+ ...typeof version === "string" && version !== "" ? { version } : {},
59
59
  relativePath,
60
60
  ...isStringRecord(parsed.dependencies) ? { dependencies: parsed.dependencies } : {},
61
61
  ...isStringRecord(parsed.devDependencies) ? { devDependencies: parsed.devDependencies } : {},
@@ -233,7 +233,7 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
233
233
  const hookReplays = yield* catalogsService.hookReplays();
234
234
  const snapshotPackages = packages.map((pkg) => PackageStateSnapshot.make({
235
235
  name: pkg.name,
236
- version: pkg.version ?? "",
236
+ ...pkg.version === void 0 ? {} : { version: pkg.version },
237
237
  relativePath: pkg.relativePath,
238
238
  dependencies: pkg.dependencies,
239
239
  devDependencies: pkg.devDependencies,
@@ -1,12 +1,27 @@
1
1
  import { unanimousVersionOf } from "./internal/importerVersions.js";
2
2
  import { CatalogSet } from "./WorkspaceCatalogs.js";
3
- import { Effect, Exit, Layer, Option, Schema } from "effect";
3
+ import { Effect, Exit, Layer, Option, Schema, SchemaTransformation } from "effect";
4
4
  import { CatalogResolver, DependencyResolutionError, DependencySpecifier, WorkspaceResolver } from "@effected/npm";
5
5
 
6
6
  //#region src/WorkspaceStateSnapshot.ts
7
7
  const EMPTY = Object.freeze(Object.create(null));
8
8
  const DependencyMap = Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY)), Schema.withConstructorDefault(Effect.succeed(EMPTY)));
9
9
  /**
10
+ * A snapshot's `version` field: absent when the manifest declared none, and
11
+ * never `""`.
12
+ *
13
+ * @remarks
14
+ * The Type side is a `NonEmptyString`, so `make` rejects `""` outright. The
15
+ * encoded side stays a plain optional string for one reason only: snapshots
16
+ * serialized while `""` was the "declared no version" sentinel must still
17
+ * decode, and they decode to the absent key they always meant — so an old
18
+ * stored value and a fresh capture of the same manifest compare equal.
19
+ */
20
+ const SnapshotVersion = Schema.optionalKey(Schema.String).pipe(Schema.decodeTo(Schema.optionalKey(Schema.NonEmptyString), SchemaTransformation.transformOptional({
21
+ decode: (encoded) => Option.filter(encoded, (version) => version !== ""),
22
+ encode: (version) => version
23
+ })));
24
+ /**
10
25
  * One workspace member as captured in a {@link WorkspaceStateSnapshot} — the
11
26
  * serializable slice a snapshot diff reads: identity, version, location, and the
12
27
  * four dependency records.
@@ -24,23 +39,24 @@ var PackageStateSnapshot = class extends Schema.Class("PackageStateSnapshot")({
24
39
  /** The package name. */
25
40
  name: Schema.NonEmptyString,
26
41
  /**
27
- * The raw `version` string, as recorded at the captured moment — or `""` for
42
+ * The raw `version` string, as recorded at the captured moment — absent for
28
43
  * a manifest that declared none.
29
44
  *
30
45
  * @remarks
31
- * `WorkspacePackage.version` is optional and a version-less member is an
32
- * ordinary pnpm shape, but this field stays a plain required string because a
33
- * snapshot is a serialized value someone stores and diffs: an absent key and
34
- * a present one would read as a change the moment one side of a diff was
35
- * captured by a different code path. **`""` is therefore the sentinel for
36
- * "the manifest declared no version", not a version anyone can use** — both
37
- * `snapshotOf` at a ref and the worktree snapshot write it, so the two sides
38
- * agree. Every resolution surface treats it as absence:
39
- * {@link WorkspaceStateSnapshot.resolve} answers `Option.none()` for a
40
- * `workspace:` specifier against it, because `some("")` would rewrite
41
- * `workspace:^` as a bare `"^"`.
46
+ * Optional exactly as `WorkspacePackage.version` is: a version-less member is
47
+ * an ordinary pnpm shape. Both capture paths — `WorkspaceSnapshots.at(ref)`
48
+ * and `WorkspaceSnapshots.worktree()` — omit the key for such a member, so
49
+ * the two sides of a diff agree without a placeholder. Never `""`: `make`
50
+ * rejects it, and a snapshot serialized when `""` was the "no version"
51
+ * sentinel decodes to the absent key.
52
+ *
53
+ * A version-less member is still a member: it appears in `packages` and
54
+ * answers {@link WorkspaceStateSnapshot.package}, but is absent from
55
+ * {@link WorkspaceStateSnapshot.versions}, resolves a `workspace:` specifier
56
+ * to `Option.none()`, and fails its snapshot-bound `WorkspaceResolver`'s
57
+ * `versionOf` typed.
42
58
  */
43
- version: Schema.String,
59
+ version: SnapshotVersion,
44
60
  /** POSIX path relative to the workspace root; `"."` for the root package. */
45
61
  relativePath: Schema.String,
46
62
  /** Production dependencies. */
@@ -166,7 +182,11 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
166
182
  #workspaceResolver;
167
183
  #resolvers;
168
184
  #versions() {
169
- if (this.#versionIndex === void 0) this.#versionIndex = new Map(this.packages.map((pkg) => [pkg.name, pkg.version]));
185
+ if (this.#versionIndex === void 0) {
186
+ const index = /* @__PURE__ */ new Map();
187
+ for (const pkg of this.packages) if (pkg.version !== void 0) index.set(pkg.name, pkg.version);
188
+ this.#versionIndex = index;
189
+ }
170
190
  return this.#versionIndex;
171
191
  }
172
192
  #packages() {
@@ -174,15 +194,13 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
174
194
  return this.#packageIndex;
175
195
  }
176
196
  /**
177
- * Every captured package's name → version. Total; O(1) after the first call.
197
+ * Every captured package's name → version, for the packages that declared
198
+ * one. Total; O(1) after the first call.
178
199
  *
179
200
  * @remarks
180
- * Values are `PackageStateSnapshot.version` verbatim, so a member whose
181
- * manifest declared no version maps to the **`""` sentinel** rather than
182
- * being absent from the map — presence here answers membership, not
183
- * "has a usable version". A caller reading a version out of this map owes
184
- * the `""` check itself; {@link WorkspaceStateSnapshot.resolve} already
185
- * makes it.
201
+ * A member whose manifest declared no version is **absent** from this map,
202
+ * so every value is a real version and presence answers "has a version",
203
+ * not membership — ask {@link WorkspaceStateSnapshot.package} for that.
186
204
  */
187
205
  get versions() {
188
206
  return this.#versions();
@@ -268,10 +286,7 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
268
286
  const fromCatalogs = this.#catalogRange(dependency, classified.name);
269
287
  return Option.isSome(fromCatalogs) ? fromCatalogs : onUnresolvedCatalog();
270
288
  }
271
- case "workspace": {
272
- const version = this.#versions().get(dependency);
273
- return version === void 0 || version === "" ? Option.none() : Option.some(version);
274
- }
289
+ case "workspace": return Option.fromUndefinedOr(this.#versions().get(dependency));
275
290
  default: return Option.none();
276
291
  }
277
292
  }
@@ -422,11 +437,13 @@ var WorkspaceStateSnapshot = class WorkspaceStateSnapshot extends Schema.Class("
422
437
  */
423
438
  get workspaceResolver() {
424
439
  if (this.#workspaceResolver === void 0) this.#workspaceResolver = Layer.succeed(WorkspaceResolver, { versionOf: (packageName) => {
425
- const version = this.#versions().get(packageName);
426
- if (version === void 0) return Effect.succeed(Option.none());
427
- if (version === "") return Effect.fail(new DependencyResolutionError({
440
+ const member = this.#packages().get(packageName);
441
+ if (member === void 0) return Effect.succeed(Option.none());
442
+ const version = member.version;
443
+ if (version === void 0) return Effect.fail(new DependencyResolutionError({
428
444
  specifier: `workspace:${packageName}`,
429
- cause: /* @__PURE__ */ new Error(`Workspace member "${packageName}" declares no version`)
445
+ reason: "no-version",
446
+ cause: void 0
430
447
  }));
431
448
  return Effect.succeed(Option.some(version));
432
449
  } });
package/WorkspacesSync.js CHANGED
@@ -120,9 +120,17 @@ const isPackage = (options, dir) => options.fileSystem.exists(options.path.join(
120
120
  * Markers match the async service exactly: a `pnpm-workspace.yaml`, or a
121
121
  * `package.json` carrying a `workspaces` field.
122
122
  *
123
+ * The ascent is unbounded unless `options.stopAt` names a ceiling
124
+ * ({@link FindWorkspaceRootSyncOptions.stopAt}), with the Effect surface's
125
+ * semantics: inclusive, resolved at lookup time. Where `WorkspaceRoot.find`
126
+ * fails `WorkspaceRootNotFoundError` carrying the resolved ceiling, this
127
+ * returns `null` — the facade is total and has no error channel, and the
128
+ * caller already holds the ceiling it passed.
129
+ *
123
130
  * @param cwd - Where to start the ascent (typically `process.cwd()`),
124
131
  * matching the Effect layers' `{ cwd }` option.
125
- * @param options - The consumer-supplied file and path operations.
132
+ * @param options - The consumer-supplied file and path operations, plus an
133
+ * optional `stopAt` ceiling.
126
134
  *
127
135
  * @example
128
136
  * ```ts
@@ -146,10 +154,12 @@ const isPackage = (options, dir) => options.fileSystem.exists(options.path.join(
146
154
  const findWorkspaceRootSync = (cwd, options) => {
147
155
  const { fileSystem, path } = options;
148
156
  let current = path.resolve(cwd);
157
+ const ceiling = options.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
149
158
  for (let depth = 0; depth < 32 * 8; depth++) {
150
159
  if (fileSystem.exists(path.join(current, "pnpm-workspace.yaml"))) return current;
151
160
  const manifest = readJson(fileSystem, path.join(current, "package.json"));
152
161
  if (manifest?.workspaces !== void 0 && manifest.workspaces !== null) return current;
162
+ if (ceiling !== void 0 && current === ceiling) return null;
153
163
  const parent = path.dirname(current);
154
164
  if (parent === current) return null;
155
165
  current = parent;