@effected/workspaces 0.28.0-next-20260927071821 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ConfigDependencyHooks.js +13 -1
- package/LockfileReader.js +7 -2
- package/PeerCheck.js +449 -36
- package/README.md +20 -5
- package/WorkspaceCatalogs.js +2 -1
- package/WorkspaceDiscovery.js +4 -3
- package/WorkspaceSnapshots.js +5 -4
- package/WorkspaceStateSnapshot.js +47 -30
- package/Workspaces.js +6 -2
- package/WorkspacesSync.js +11 -1
- package/index.d.ts +972 -721
- package/internal/configDependencyFetch.js +39 -6
- package/internal/configDependencyResolution.js +1 -0
- package/internal/layerRoot.js +14 -0
- package/package.json +4 -4
- package/testing.d.ts +5 -0
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
|
|
175
|
-
const report = PeerCheck.run(lockfile, {
|
|
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
|
|
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"`.
|
|
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/WorkspaceCatalogs.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findLayerRoot } from "./internal/layerRoot.js";
|
|
1
2
|
import { WorkspaceRoot } from "./WorkspaceRoot.js";
|
|
2
3
|
import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
|
|
3
4
|
import { ConfigDependencyHooks, NoPeerDependencyRules } from "./ConfigDependencyHooks.js";
|
|
@@ -342,7 +343,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
342
343
|
}));
|
|
343
344
|
}));
|
|
344
345
|
const assemble = Effect.gen(function* () {
|
|
345
|
-
const root = yield*
|
|
346
|
+
const root = yield* findLayerRoot(roots, options);
|
|
346
347
|
const lockfileOutputs = yield* lockfiles.read().pipe(Effect.map((lockfile) => ({
|
|
347
348
|
catalogs: CatalogSet.fromLockfile(lockfile),
|
|
348
349
|
importerVersions: importerVersionsOf(lockfile)
|
package/WorkspaceDiscovery.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { enumerate } from "./internal/enumerate.js";
|
|
2
|
+
import { findLayerRoot } from "./internal/layerRoot.js";
|
|
2
3
|
import { readPatterns } from "./internal/patterns.js";
|
|
3
4
|
import { WorkspacePackage } from "./WorkspacePackage.js";
|
|
4
5
|
import { WorkspaceRoot } from "./WorkspaceRoot.js";
|
|
@@ -256,8 +257,7 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
|
|
|
256
257
|
};
|
|
257
258
|
}).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
|
|
258
259
|
const discover = Effect.gen(function* () {
|
|
259
|
-
const
|
|
260
|
-
const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd(), stopAt === void 0 ? void 0 : { stopAt }));
|
|
260
|
+
const root = yield* findLayerRoot(roots, options);
|
|
261
261
|
return yield* discoverAt(root);
|
|
262
262
|
});
|
|
263
263
|
const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(discover, Duration.infinity);
|
|
@@ -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
|
-
|
|
551
|
+
reason: "no-version",
|
|
552
|
+
cause: void 0
|
|
552
553
|
})) : Effect.succeed(Option.some(version));
|
|
553
554
|
}));
|
|
554
555
|
} };
|
package/WorkspaceSnapshots.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findLayerRoot } from "./internal/layerRoot.js";
|
|
1
2
|
import { manifestPatternsOf, pnpmPatternsOf } from "./internal/patterns.js";
|
|
2
3
|
import { WorkspaceRoot } from "./WorkspaceRoot.js";
|
|
3
4
|
import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
|
|
@@ -51,10 +52,10 @@ const snapshotOf = (content, relativePath) => {
|
|
|
51
52
|
const parsed = parseJsonObject(content.value);
|
|
52
53
|
const name = parsed.name;
|
|
53
54
|
if (typeof name !== "string" || name.length === 0) return Option.none();
|
|
54
|
-
const version =
|
|
55
|
+
const version = parsed.version;
|
|
55
56
|
return Option.some(PackageStateSnapshot.make({
|
|
56
57
|
name,
|
|
57
|
-
version,
|
|
58
|
+
...typeof version === "string" && version !== "" ? { version } : {},
|
|
58
59
|
relativePath,
|
|
59
60
|
...isStringRecord(parsed.dependencies) ? { dependencies: parsed.dependencies } : {},
|
|
60
61
|
...isStringRecord(parsed.devDependencies) ? { devDependencies: parsed.devDependencies } : {},
|
|
@@ -210,7 +211,7 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
210
211
|
const atCaches = /* @__PURE__ */ new Map();
|
|
211
212
|
return {
|
|
212
213
|
at: Effect.fn("WorkspaceSnapshots.at")(function* (ref) {
|
|
213
|
-
const root = yield*
|
|
214
|
+
const root = yield* findLayerRoot(roots, options);
|
|
214
215
|
const key = `${root}\0${ref}`;
|
|
215
216
|
let memo = atCaches.get(key);
|
|
216
217
|
if (memo === void 0) {
|
|
@@ -232,7 +233,7 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
232
233
|
const hookReplays = yield* catalogsService.hookReplays();
|
|
233
234
|
const snapshotPackages = packages.map((pkg) => PackageStateSnapshot.make({
|
|
234
235
|
name: pkg.name,
|
|
235
|
-
version: pkg.version
|
|
236
|
+
...pkg.version === void 0 ? {} : { version: pkg.version },
|
|
236
237
|
relativePath: pkg.relativePath,
|
|
237
238
|
dependencies: pkg.dependencies,
|
|
238
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 —
|
|
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
|
|
32
|
-
* ordinary pnpm shape
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* {@link WorkspaceStateSnapshot.
|
|
40
|
-
*
|
|
41
|
-
* `
|
|
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:
|
|
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)
|
|
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
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
|
426
|
-
if (
|
|
427
|
-
|
|
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
|
-
|
|
445
|
+
reason: "no-version",
|
|
446
|
+
cause: void 0
|
|
430
447
|
}));
|
|
431
448
|
return Effect.succeed(Option.some(version));
|
|
432
449
|
} });
|
package/Workspaces.js
CHANGED
|
@@ -48,7 +48,8 @@ const localExecLayer = (options) => Layer.effect(LocalExec, Effect.gen(function*
|
|
|
48
48
|
const detector = yield* PackageManagerDetector;
|
|
49
49
|
return { context: Effect.gen(function* () {
|
|
50
50
|
const cwd = options?.cwd ?? globalThis.process?.cwd?.() ?? "/";
|
|
51
|
-
const
|
|
51
|
+
const stopAt = options?.stopAt;
|
|
52
|
+
const root = yield* roots.find(cwd, stopAt === void 0 ? void 0 : { stopAt }).pipe(Effect.asSome, Effect.orElseSucceed(Option.none));
|
|
52
53
|
if (Option.isNone(root)) return Option.none();
|
|
53
54
|
const detected = yield* detector.detect(root.value).pipe(Effect.asSome, Effect.catchTag("PackageManagerDetectionError", () => Effect.succeed(Option.none())), Effect.mapError((cause) => new LocalExecError({
|
|
54
55
|
directory: root.value,
|
|
@@ -289,7 +290,10 @@ var Workspaces = class {
|
|
|
289
290
|
* verbatim.
|
|
290
291
|
*
|
|
291
292
|
* `directory` is the resolved **workspace root**, not the caller's cwd: a
|
|
292
|
-
* project-local launcher has to run where the workspace is.
|
|
293
|
+
* project-local launcher has to run where the workspace is. `stopAt` caps
|
|
294
|
+
* that ascent exactly as {@link WorkspacesOptions.stopAt} does, and a
|
|
295
|
+
* ceiling with no root at or below it is the same `Option.none()` as no
|
|
296
|
+
* root at all.
|
|
293
297
|
*
|
|
294
298
|
* A consumer with no monorepo never needs this layer, and therefore never
|
|
295
299
|
* installs this package — `LocalExec.layerNone` and `LocalExec.layerFor`
|
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;
|