@effected/workspaces 0.1.0 → 0.2.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/ChangeDetector.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
2
  import { DependencyGraph } from "./DependencyGraph.js";
3
- import { Context, Effect, Layer, Schema } from "effect";
4
3
  import { Git } from "@effected/git";
4
+ import { Context, Effect, Layer, Schema } from "effect";
5
5
 
6
6
  //#region src/ChangeDetector.ts
7
7
  /**
@@ -1,6 +1,6 @@
1
- import { CatalogAssemblyError } from "./CatalogAssemblyError.js";
2
1
  import { normalize } from "./internal/catalogs.js";
3
2
  import { Context, Effect, Layer, Predicate, Result } from "effect";
3
+ import { CatalogAssemblyError } from "@effected/npm";
4
4
  import { join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
@@ -95,7 +95,7 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
95
95
  * (in process, no subprocess) and replays its `updateConfig` hook over the
96
96
  * seed, in declaration order. A dependency without a `pnpmfile.cjs` contributes
97
97
  * nothing; a dependency whose file fails to load or replay fails typed with a
98
- * `hooks`-source {@link CatalogAssemblyError}, never a silent skip.
98
+ * `hooks`-source `CatalogAssemblyError`, never a silent skip.
99
99
  *
100
100
  * @remarks
101
101
  * Node-coupled by design — the `node:fs` / `node:path` / `node:url` imports are
package/README.md CHANGED
@@ -110,20 +110,52 @@ const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
110
110
  // Layer<CatalogResolver | WorkspaceResolver, never, FileSystem | Path>
111
111
  ```
112
112
 
113
+ `Workspaces.resolverLayer(options?)` is that wiring in one call: the two contracts over a full workspace stack, needing only `FileSystem` and `Path` from you. A fresh layer per call is the point — root discovery re-runs each time, including the `process.cwd()` read when `options.cwd` is omitted, so a build tool that changes directory between manifests stays correct. It wires the config-dependency replay path; compose `Workspaces.resolvers` with `Workspaces.layer` yourself if config-dependency code must not run.
114
+
115
+ For whole manifests, `Workspaces.resolveManifest` is the one-shot path over `@effected/npm`'s tolerant `Manifest` model:
116
+
117
+ ```ts
118
+ import { Manifest } from "@effected/npm";
119
+ import { Workspaces } from "@effected/workspaces";
120
+ import { Effect } from "effect";
121
+
122
+ const program = Effect.gen(function* () {
123
+ const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
124
+ const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
125
+ return resolved.toRecord();
126
+ });
127
+ // needsResolution is pure — checking it first skips catalog assembly entirely
128
+ // when no dependency field carries a catalog: or workspace: specifier
129
+ ```
130
+
131
+ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyError`: at the manifest level "no catalog entry" means the manifest cannot be projected to concrete ranges.
132
+
113
133
  ## The synchronous escape hatch
114
134
 
115
- Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they are **Node-only and synchronous**:
135
+ Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply — the module itself imports nothing platform-shaped, and Node's built-ins satisfy the operations one-liner each:
116
136
 
117
137
  ```ts
138
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
139
+ import * as path from "node:path";
118
140
  import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
119
141
 
120
- const root = findWorkspaceRootSync();
121
- const packages = root === null ? [] : getWorkspacePackagesSync(root);
142
+ const options = {
143
+ fileSystem: {
144
+ exists: existsSync,
145
+ readFile: (p: string) => readFileSync(p, "utf8"),
146
+ readDirectory: (p: string) => readdirSync(p),
147
+ isDirectory: (p: string) => statSync(p).isDirectory(),
148
+ },
149
+ path, // node:path satisfies SyncPath verbatim
150
+ };
151
+
152
+ const root = findWorkspaceRootSync(options);
153
+ const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
122
154
  // root: the workspace root path, or null when none is found above the cwd
123
155
  // packages: the discovered workspace packages, empty when there is no root
124
156
  ```
125
157
 
126
- Both entry points drive one traversal state machine the same dequeue order, depth rule, visit budget and `node_modules` prune so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
158
+ Windows correctness is the operations you pass — `node:path` on Windows is already win32-appropriate. Both entry points drive one traversal state machine (the same dequeue order, depth rule, visit budget and `node_modules` prune), so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
127
159
 
128
160
  ## Error handling
129
161
 
@@ -144,21 +176,22 @@ const program = Effect.gen(function* () {
144
176
  );
145
177
  ```
146
178
 
147
- `WorkspaceRootNotFoundError`, `WorkspaceDiscoveryError`, `WorkspacePatternError`, `PackageNotFoundError`, `WorkspaceManifestError`, `PackageManagerDetectionError`, `CatalogAssemblyError`, `LockfileReadError`, `CyclicDependencyError` and `ChangeDetectionError` each name one thing that can actually go wrong, and each method's error channel is narrowed to the ones it can produce. Change detection additionally surfaces `@effected/git`'s typed git errors, such as `NotARepositoryError`.
179
+ `WorkspaceRootNotFoundError`, `WorkspaceDiscoveryError`, `WorkspacePatternError`, `PackageNotFoundError`, `WorkspaceManifestError`, `PackageManagerDetectionError`, `CatalogAssemblyError`, `LockfileReadError`, `CyclicDependencyError` and `ChangeDetectionError` each name one thing that can actually go wrong, and each method's error channel is narrowed to the ones it can produce. `CatalogAssemblyError` is defined in `@effected/npm`, beside the resolver contract that names it in its channel — import it from there. Change detection additionally surfaces `@effected/git`'s typed git errors, such as `NotARepositoryError`.
148
180
 
149
181
  ## Features
150
182
 
151
183
  - `Workspaces.layer` / `Workspaces.layerWithGit` / `Workspaces.resolvers` — the composite layers, split on requirements rather than feature flags: a filesystem, a filesystem plus a subprocess, and the two `@effected/npm` resolver contracts.
184
+ - `Workspaces.resolverLayer` / `Workspaces.resolveManifest` — the one-call manifest-resolution path: a fresh, unmemoized layer per call so root discovery follows your cwd, and one-shot resolution of a whole `Manifest` against the real workspace.
152
185
  - `WorkspaceRoot` — root discovery from a `cwd`, over `WORKSPACE_MARKERS`.
153
186
  - `WorkspaceDiscovery` — package enumeration with a bounded descent for segment-crossing `packages/**` patterns, plus per-package lookup.
154
- - `WorkspacePackage` — a deliberately tolerant manifest model, so one member with an odd version cannot fail discovery for the whole repo. `WorkspacePackage.manifest(pkg)` is the opt-in bridge to `@effected/package-json`'s strict `Package`.
187
+ - `WorkspacePackage` — a deliberately tolerant manifest model, so one member with an odd version cannot fail discovery for the whole repo. `manifestRecord` keeps the as-read `package.json` for tolerant access to fields outside the typed slice without a second read; `WorkspacePackage.manifest(pkg)` re-reads and is the opt-in bridge to `@effected/package-json`'s strict `Package`.
155
188
  - `DependencyGraph` — a value class over discovered packages: `levels()` for parallel build tiers, the flattened topological order, and `CyclicDependencyError` when there isn't one.
156
189
  - `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field.
157
190
  - `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages.
158
191
  - `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
159
192
  - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
160
193
  - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). The default layer implements npm's semantics; swap the layer if yours differ.
161
- - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the Node-only synchronous escape hatch for config-time callers that cannot await.
194
+ - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
162
195
 
163
196
  ## License
164
197
 
@@ -1,10 +1,9 @@
1
- import { CatalogAssemblyError } from "./CatalogAssemblyError.js";
2
1
  import { WorkspaceRoot } from "./WorkspaceRoot.js";
3
2
  import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
4
3
  import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
5
4
  import { LockfileReader } from "./LockfileReader.js";
6
5
  import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
7
- import { CatalogResolver, DependencyResolutionError } from "@effected/npm";
6
+ import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError } from "@effected/npm";
8
7
  import { Yaml } from "@effected/yaml";
9
8
 
10
9
  //#region src/WorkspaceCatalogs.ts
@@ -99,7 +98,7 @@ entries: Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String
99
98
  * load-bearing for: a present-but-malformed `workspaces` shape (a number, a
100
99
  * string, an object with a malformed `packages` / `catalog` / `catalogs`), or
101
100
  * the default catalog declared twice — once as `workspaces.catalog` and again
102
- * as `workspaces.catalogs.default` — fails with {@link CatalogAssemblyError}
101
+ * as `workspaces.catalogs.default` — fails with `CatalogAssemblyError`
103
102
  * naming what was wrong. An absent `workspaces` field, one explicitly `null`,
104
103
  * or the plain array form (npm/yarn patterns, which carry no catalogs) yields
105
104
  * the empty set. Presence is checked structurally, so an explicitly-declared
@@ -269,7 +268,7 @@ const validatePnpmWorkspaceCatalogs = (document) => {
269
268
  * within a catalog. An unreadable or absent lockfile degrades to no lockfile
270
269
  * catalogs rather than failing (the inline declaration is the source of truth, the
271
270
  * lockfile a record of what was installed); a malformed inline source **fails
272
- * typed** with {@link CatalogAssemblyError}, because a silently-empty catalog read
271
+ * typed** with `CatalogAssemblyError`, because a silently-empty catalog read
273
272
  * is the "every dependency looks newly added" bug.
274
273
  *
275
274
  * Assembly is deferred to the first call and memoized success-only, matching
@@ -372,19 +371,22 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
372
371
  *
373
372
  * @remarks
374
373
  * `rangeOf` returns `Option.none()` for a dependency no catalog declares, per
375
- * the contract's convention; `DependencyResolutionError` is reserved for a
376
- * failure of the resolution *mechanism*an unfindable workspace root, an
377
- * unreadable or malformed `pnpm-workspace.yaml`.
374
+ * the contract's convention. A failed catalog *assembly* — an unreadable or
375
+ * malformed `pnpm-workspace.yaml`, a broken config-dependency hook passes
376
+ * through **typed** as the contract's `CatalogAssemblyError` (it used to be
377
+ * folded into `DependencyResolutionError`'s defect `cause`, which forced
378
+ * consumers to `_tag`-sniff `unknown`); only the remaining mechanism failure,
379
+ * an unfindable workspace root, is wrapped as `DependencyResolutionError`.
378
380
  */
379
381
  static catalogResolver = Layer.effect(CatalogResolver, Effect.gen(function* () {
380
382
  const catalogs = yield* WorkspaceCatalogs;
381
- return { rangeOf: (packageName, catalog) => catalogs.set().pipe(Effect.map((set) => set.rangeOf(packageName, catalog)), Effect.mapError((cause) => new DependencyResolutionError({
383
+ return { rangeOf: (packageName, catalog) => catalogs.set().pipe(Effect.map((set) => set.rangeOf(packageName, catalog)), Effect.catchTag("WorkspaceRootNotFoundError", (cause) => Effect.fail(new DependencyResolutionError({
382
384
  specifier: Option.match(catalog, {
383
385
  onNone: () => "catalog:",
384
386
  onSome: (name) => `catalog:${name}`
385
387
  }),
386
388
  cause
387
- }))) };
389
+ })))) };
388
390
  }));
389
391
  };
390
392
 
@@ -184,6 +184,7 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
184
184
  path: directory,
185
185
  packageJsonPath,
186
186
  relativePath,
187
+ manifestRecord: raw,
187
188
  ...typeof raw.private === "boolean" ? { private: raw.private } : {},
188
189
  ...isStringRecord(raw.dependencies) ? { dependencies: raw.dependencies } : {},
189
190
  ...isStringRecord(raw.devDependencies) ? { devDependencies: raw.devDependencies } : {},
@@ -5,6 +5,7 @@ import { Package } from "@effected/package-json";
5
5
 
6
6
  //#region src/WorkspacePackage.ts
7
7
  const EMPTY = Object.freeze(Object.create(null));
8
+ const EMPTY_MANIFEST = Object.freeze(Object.create(null));
8
9
  /**
9
10
  * The `publishConfig` fields workspace tooling reads.
10
11
  *
@@ -100,7 +101,21 @@ var WorkspacePackage = class WorkspacePackage extends Schema.Class("WorkspacePac
100
101
  /** Optional dependencies. */
101
102
  optionalDependencies: DependencyMap,
102
103
  /** The `publishConfig` block, when present. */
103
- publishConfig: Schema.optionalKey(PublishConfig)
104
+ publishConfig: Schema.optionalKey(PublishConfig),
105
+ /**
106
+ * The package's `package.json` as read — tolerant access to every field
107
+ * outside the typed discovery slice (`scripts`, `exports`, …) without a
108
+ * second file read.
109
+ *
110
+ * @remarks
111
+ * Values are `unknown` and exactly what discovery parsed; nothing here is
112
+ * validated beyond being a record. For the strict typed model use
113
+ * `manifest()`, which deliberately **re-reads** the file — a point-in-time
114
+ * refresh this captured record cannot provide. Defaults to `{}` for
115
+ * construction sites and previously-serialized values that predate the
116
+ * field.
117
+ */
118
+ manifestRecord: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY_MANIFEST)), Schema.withConstructorDefault(Effect.succeed(EMPTY_MANIFEST)))
104
119
  }) {
105
120
  /** Whether this is the workspace root package. */
106
121
  get isRootWorkspace() {
Binary file
@@ -140,6 +140,12 @@ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot"
140
140
  * THIS snapshot's catalog set — so code written to the contract resolves
141
141
  * `catalog:` specifiers as of this ref. Built once per instance and cached, so
142
142
  * it memoizes by reference.
143
+ *
144
+ * @remarks
145
+ * The contract's error channel (`CatalogAssemblyError` /
146
+ * `DependencyResolutionError`) is satisfied vacuously: a snapshot's catalogs
147
+ * were already assembled when it was captured, so this resolver is total —
148
+ * `rangeOf` never fails.
143
149
  */
144
150
  get catalogResolver() {
145
151
  if (this.#catalogResolver === void 0) this.#catalogResolver = Layer.succeed(CatalogResolver, { rangeOf: (packageName, catalog) => Effect.succeed(this.catalogs.rangeOf(packageName, catalog)) });
package/Workspaces.js CHANGED
@@ -6,8 +6,8 @@ import { LockfileReader } from "./LockfileReader.js";
6
6
  import { PublishabilityDetector } from "./Publishability.js";
7
7
  import { WorkspaceCatalogs } from "./WorkspaceCatalogs.js";
8
8
  import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
9
- import { Layer } from "effect";
10
9
  import { Git } from "@effected/git";
10
+ import { Effect, Layer } from "effect";
11
11
 
12
12
  //#region src/Workspaces.ts
13
13
  /**
@@ -98,6 +98,77 @@ const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDis
98
98
  */
99
99
  const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
100
100
  /**
101
+ * The one-call resolver factory: {@link Workspaces.resolvers} pre-wired over
102
+ * {@link Workspaces.layerWithConfigDependencies}, so the two `@effected/npm`
103
+ * contracts (`CatalogResolver`, `WorkspaceResolver`) need only a platform
104
+ * (`FileSystem` + `Path`) from the consumer.
105
+ *
106
+ * @remarks
107
+ * This is deliberately a **parameterized layer function, and the fresh layer
108
+ * per call is the feature**: layers memoize by reference, so each call mints
109
+ * an unmemoized layer whose root discovery re-runs — including a per-call
110
+ * `process.cwd()` read when `options.cwd` is omitted. A build tool that
111
+ * changes directory between manifests gets a correct re-discovery each time
112
+ * precisely because nothing is shared across calls. When you *want* sharing,
113
+ * bind one call's result to a `const` and provide that; the memoization rule
114
+ * is unchanged, this factory just refuses to hide it.
115
+ *
116
+ * Catalog assembly replays config-dependency `pnpmfile` hooks (the
117
+ * `layerWithConfigDependencies` path) — the semantics a real pnpm install
118
+ * has. Compose {@link Workspaces.resolvers} with {@link Workspaces.layer}
119
+ * yourself if config-dependency code must not run in process.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * import { Workspaces } from "@effected/workspaces";
124
+ * import { Effect } from "effect";
125
+ *
126
+ * const program = doSomethingWithResolvers.pipe(
127
+ * Effect.provide(Workspaces.resolverLayer()),
128
+ * );
129
+ * ```
130
+ *
131
+ * @public
132
+ */
133
+ const resolverLayer = (options) => resolvers.pipe(Layer.provide(layerWithConfigDependencies(options)));
134
+ /**
135
+ * Resolve every `catalog:` and `workspace:` specifier in one `Manifest`
136
+ * against the real workspace, in one call — the 90% path. Decode stays at the
137
+ * consumer's edge: build the `Manifest` with `Manifest.decode` (from
138
+ * `@effected/npm`), hand it here, and get a new `Manifest` back with concrete
139
+ * ranges; `toRecord()` returns to the wire shape.
140
+ *
141
+ * @remarks
142
+ * Composes `manifest.resolve()` with a fresh {@link Workspaces.resolverLayer}
143
+ * per call, so the workspace root is re-discovered from `options.cwd` (or the
144
+ * current `process.cwd()`) on every invocation. Consumers processing many
145
+ * manifests should check `manifest.needsResolution` first and skip the call
146
+ * entirely when no dependency field carries a `catalog:`/`workspace:`
147
+ * specifier — that predicate is pure and avoids catalog assembly altogether.
148
+ *
149
+ * A specifier the workspace cannot answer fails typed as
150
+ * `UnresolvedDependencyError`; assembly and mechanism failures surface as
151
+ * `CatalogAssemblyError` / `DependencyResolutionError`.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { Manifest } from "@effected/npm";
156
+ * import { Workspaces } from "@effected/workspaces";
157
+ * import { Effect } from "effect";
158
+ *
159
+ * const program = Effect.gen(function* () {
160
+ * const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
161
+ * const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
162
+ * return resolved.toRecord();
163
+ * });
164
+ * ```
165
+ *
166
+ * @public
167
+ */
168
+ const resolveManifest = Effect.fn("Workspaces.resolveManifest")(function* (manifest, options) {
169
+ return yield* manifest.resolve().pipe(Effect.provide(resolverLayer(options)));
170
+ });
171
+ /**
101
172
  * The composite layers.
102
173
  *
103
174
  * @public
@@ -106,6 +177,8 @@ const Workspaces = {
106
177
  layer,
107
178
  layerWithConfigDependencies,
108
179
  layerWithGit,
180
+ resolveManifest,
181
+ resolverLayer,
109
182
  resolvers
110
183
  };
111
184
 
package/WorkspacesSync.js CHANGED
@@ -4,8 +4,6 @@ import { PublishConfig, WorkspacePackage } from "./WorkspacePackage.js";
4
4
  import { Effect, Exit, Schema } from "effect";
5
5
  import { GlobSet } from "@effected/glob";
6
6
  import { Yaml } from "@effected/yaml";
7
- import { dirname, join, resolve } from "node:path";
8
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
7
 
10
8
  //#region src/WorkspacesSync.ts
11
9
  /**
@@ -17,68 +15,84 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
15
  * only on `undefined` sails straight into `raw.name` and throws a `TypeError`.
18
16
  * These functions are documented as total and are reached from a Vitest config,
19
17
  * which has nowhere to put a crash: malformed input must be *skipped*, never a
20
- * defect.
18
+ * defect. A throwing consumer `readFile` lands in the same catch as a JSON
19
+ * syntax error — an unusable manifest either way.
21
20
  */
22
- const readJson = (file) => {
21
+ const readJson = (fileSystem, file) => {
23
22
  let parsed;
24
23
  try {
25
- parsed = JSON.parse(readFileSync(file, "utf8"));
24
+ parsed = JSON.parse(fileSystem.readFile(file));
26
25
  } catch {
27
26
  return;
28
27
  }
29
28
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
30
29
  };
31
- /** Whether `dir` is a directory. Never throws. */
32
- const isDirectory = (dir) => {
30
+ /** Whether `dir` is a directory. Never throws — a throwing consumer op reads as `false`. */
31
+ const isDirectory = (fileSystem, dir) => {
33
32
  try {
34
- return statSync(dir).isDirectory();
33
+ return fileSystem.isDirectory(dir);
35
34
  } catch {
36
35
  return false;
37
36
  }
38
37
  };
39
38
  /** Whether `dir` holds a `package.json`. */
40
- const isPackage = (dir) => existsSync(join(dir, "package.json"));
39
+ const isPackage = (options, dir) => options.fileSystem.exists(options.path.join(dir, "package.json"));
41
40
  /**
42
- * The nearest workspace root at or above `cwd`, or `null`.
41
+ * The nearest workspace root at or above `options.cwd`, or `null`.
43
42
  *
44
43
  * @remarks
45
- * **Synchronous and Node-only.** The Effect surface is `WorkspaceRoot`; reach
46
- * for this one only where you genuinely cannot run an Effect — a Vitest config
47
- * being the motivating case.
44
+ * **Synchronous.** The Effect surface is `WorkspaceRoot`; reach for this one
45
+ * only where you genuinely cannot run an Effect — a Vitest config being the
46
+ * motivating case. The file and path operations are the caller's
47
+ * ({@link FindWorkspaceRootSyncOptions}); this module imports no `node:*` and
48
+ * assumes no posix.
48
49
  *
49
50
  * Markers match the async service exactly: a `pnpm-workspace.yaml`, or a
50
51
  * `package.json` carrying a `workspaces` field.
51
52
  *
52
- * @param cwd - Where to start the ascent. Defaults to `process.cwd()`.
53
+ * @param options - The consumer-supplied file and path operations, plus the
54
+ * optional `cwd` to start from (defaulting to `process.cwd()`).
53
55
  *
54
56
  * @example
55
57
  * ```ts
58
+ * import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
59
+ * import * as path from "node:path";
56
60
  * import { findWorkspaceRootSync } from "@effected/workspaces";
57
61
  *
58
- * const root = findWorkspaceRootSync();
62
+ * const root = findWorkspaceRootSync({
63
+ * fileSystem: {
64
+ * exists: existsSync,
65
+ * readFile: (p) => readFileSync(p, "utf8"),
66
+ * readDirectory: (p) => readdirSync(p),
67
+ * isDirectory: (p) => statSync(p).isDirectory(),
68
+ * },
69
+ * path,
70
+ * });
59
71
  * ```
60
72
  *
61
73
  * @public
62
74
  */
63
- const findWorkspaceRootSync = (cwd) => {
64
- let current = resolve(cwd ?? process.cwd());
75
+ const findWorkspaceRootSync = (options) => {
76
+ const { fileSystem, path } = options;
77
+ let current = path.resolve(options.cwd ?? process.cwd());
65
78
  for (let depth = 0; depth < 32 * 8; depth++) {
66
- if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
67
- const manifest = readJson(join(current, "package.json"));
79
+ if (fileSystem.exists(path.join(current, "pnpm-workspace.yaml"))) return current;
80
+ const manifest = readJson(fileSystem, path.join(current, "package.json"));
68
81
  if (manifest?.workspaces !== void 0 && manifest.workspaces !== null) return current;
69
- const parent = dirname(current);
82
+ const parent = path.dirname(current);
70
83
  if (parent === current) return null;
71
84
  current = parent;
72
85
  }
73
86
  return null;
74
87
  };
75
88
  /** The workspace `packages:` patterns for `root`, matching `internal/patterns.ts`'s precedence. */
76
- const readPatternsSync = (root) => {
77
- const workspaceYaml = join(root, "pnpm-workspace.yaml");
78
- if (existsSync(workspaceYaml)) {
89
+ const readPatternsSync = (options, root) => {
90
+ const { fileSystem, path } = options;
91
+ const workspaceYaml = path.join(root, "pnpm-workspace.yaml");
92
+ if (fileSystem.exists(workspaceYaml)) {
79
93
  let text = "";
80
94
  try {
81
- text = readFileSync(workspaceYaml, "utf8");
95
+ text = fileSystem.readFile(workspaceYaml);
82
96
  } catch {
83
97
  text = "";
84
98
  }
@@ -88,12 +102,12 @@ const readPatternsSync = (root) => {
88
102
  if (patterns.length > 0) return patterns;
89
103
  }
90
104
  }
91
- return manifestPatternsOf(readJson(join(root, "package.json")) ?? {});
105
+ return manifestPatternsOf(readJson(fileSystem, path.join(root, "package.json")) ?? {});
92
106
  };
93
107
  /** Build a `WorkspacePackage` from a directory, or `null` if its manifest is unusable. */
94
- const readPackageSync = (directory, relativePath) => {
95
- const packageJsonPath = join(directory, "package.json");
96
- const raw = readJson(packageJsonPath);
108
+ const readPackageSync = (options, directory, relativePath) => {
109
+ const packageJsonPath = options.path.join(directory, "package.json");
110
+ const raw = readJson(options.fileSystem, packageJsonPath);
97
111
  if (raw === void 0) return null;
98
112
  const name = raw.name;
99
113
  const version = raw.version;
@@ -113,6 +127,7 @@ const readPackageSync = (directory, relativePath) => {
113
127
  devDependencies: stringRecord(raw.devDependencies) ?? {},
114
128
  peerDependencies: stringRecord(raw.peerDependencies) ?? {},
115
129
  optionalDependencies: stringRecord(raw.optionalDependencies) ?? {},
130
+ manifestRecord: raw,
116
131
  ...config !== void 0 && Exit.isSuccess(config) ? { publishConfig: config.value } : {}
117
132
  });
118
133
  };
@@ -120,7 +135,10 @@ const readPackageSync = (directory, relativePath) => {
120
135
  * Every workspace package under `root`, root package first.
121
136
  *
122
137
  * @remarks
123
- * **Synchronous and Node-only.** The Effect surface is `WorkspaceDiscovery`.
138
+ * **Synchronous.** The Effect surface is `WorkspaceDiscovery`. The file and
139
+ * path operations are the caller's ({@link GetWorkspacePackagesSyncOptions}
140
+ * extends {@link WorkspacesSyncOptions}); this module imports no `node:*` and
141
+ * assumes no posix.
124
142
  *
125
143
  * Pattern semantics are not merely "shared" with the Effect enumerator — both
126
144
  * drive the **same traversal state machine** (`internal/traverse.ts`), so the
@@ -137,54 +155,67 @@ const readPackageSync = (directory, relativePath) => {
137
155
  * the enumerator's defect.
138
156
  *
139
157
  * @param root - The workspace root, from {@link findWorkspaceRootSync}.
140
- * @param options - Traversal bounds; see {@link GetWorkspacePackagesSyncOptions}.
158
+ * @param options - The consumer-supplied operations and traversal bounds; see
159
+ * {@link GetWorkspacePackagesSyncOptions}.
141
160
  *
142
161
  * @example
143
162
  * ```ts
163
+ * import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
164
+ * import * as path from "node:path";
144
165
  * import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
145
166
  *
146
- * const root = findWorkspaceRootSync();
147
- * const packages = root === null ? [] : getWorkspacePackagesSync(root);
167
+ * const ops = {
168
+ * fileSystem: {
169
+ * exists: existsSync,
170
+ * readFile: (p: string) => readFileSync(p, "utf8"),
171
+ * readDirectory: (p: string) => readdirSync(p),
172
+ * isDirectory: (p: string) => statSync(p).isDirectory(),
173
+ * },
174
+ * path,
175
+ * };
176
+ * const root = findWorkspaceRootSync(ops);
177
+ * const packages = root === null ? [] : getWorkspacePackagesSync(root, ops);
148
178
  * ```
149
179
  *
150
180
  * @public
151
181
  */
152
182
  const getWorkspacePackagesSync = (root, options) => {
153
- const maxDepth = options?.maxDepth ?? 32;
183
+ const { fileSystem, path } = options;
184
+ const maxDepth = options.maxDepth ?? 32;
154
185
  if (!isValidMaxDepth(maxDepth)) throw new RangeError(`getWorkspacePackagesSync: ${badMaxDepthMessage(maxDepth)}`);
155
- const patterns = readPatternsSync(root);
186
+ const patterns = readPatternsSync(options, root);
156
187
  const compiled = Effect.runSyncExit(GlobSet.compile(patterns));
157
188
  if (Exit.isFailure(compiled)) return [];
158
189
  const globs = compiled.value;
159
190
  const included = /* @__PURE__ */ new Map();
160
191
  for (const literal of globs.literals) {
161
- const absolute = join(root, literal);
162
- if (isPackage(absolute)) included.set(literal, absolute);
192
+ const absolute = path.join(root, literal);
193
+ if (isPackage(options, absolute)) included.set(literal, absolute);
163
194
  }
164
195
  for (const wildcard of globs.wildcards) {
165
196
  const base = wildcard.enumerationPrefix.replace(/\/$/, "");
166
- const absoluteBase = join(root, base);
167
- if (!isDirectory(absoluteBase)) continue;
197
+ const absoluteBase = path.join(root, base);
198
+ if (!isDirectory(fileSystem, absoluteBase)) continue;
168
199
  const traversal = new Traversal(base, absoluteBase, maxDepth);
169
200
  let stopped = false;
170
201
  for (let current = traversal.next(); current !== void 0 && !stopped; current = traversal.next()) {
171
202
  if (traversal.charge() !== void 0) break;
172
203
  let entries = [];
173
204
  try {
174
- entries = readdirSync(current.absolute);
205
+ entries = fileSystem.readDirectory(current.absolute);
175
206
  } catch {
176
207
  continue;
177
208
  }
178
209
  for (const entry of entries) {
179
210
  if (isPruned(entry)) continue;
180
211
  const relative = joinRelative(current.relative, entry);
181
- const absolute = join(current.absolute, entry);
182
- if (!isDirectory(absolute)) continue;
212
+ const absolute = path.join(current.absolute, entry);
213
+ if (!isDirectory(fileSystem, absolute)) continue;
183
214
  if (wildcard.crossesSegments && !traversal.admits(current)) {
184
215
  stopped = true;
185
216
  break;
186
217
  }
187
- if (wildcard.matches(relative) && isPackage(absolute)) included.set(relative, absolute);
218
+ if (wildcard.matches(relative) && isPackage(options, absolute)) included.set(relative, absolute);
188
219
  if (!wildcard.crossesSegments) continue;
189
220
  traversal.push(current, relative, absolute);
190
221
  }
@@ -194,10 +225,10 @@ const getWorkspacePackagesSync = (root, options) => {
194
225
  for (const [relativePath, absolute] of [...included.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
195
226
  if (relativePath === "." || absolute === root) continue;
196
227
  if (globs.excludes.some((exclude) => exclude.matches(relativePath))) continue;
197
- const pkg = readPackageSync(absolute, relativePath);
228
+ const pkg = readPackageSync(options, absolute, relativePath);
198
229
  if (pkg !== null) members.push(pkg);
199
230
  }
200
- const rootPackage = readPackageSync(root, ".");
231
+ const rootPackage = readPackageSync(options, root, ".");
201
232
  return rootPackage === null ? members : [rootPackage, ...members];
202
233
  };
203
234
 
package/index.d.ts CHANGED
@@ -1,44 +1,10 @@
1
- import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
2
1
  import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
3
- import { CatalogResolver, WorkspaceResolver } from "@effected/npm";
2
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
3
+ import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, Manifest, UnresolvedDependencyError, WorkspaceResolver } from "@effected/npm";
4
4
  import { GlobPattern } from "@effected/glob";
5
5
  import { Lockfile, LockfileFramingError, LockfileIntegrity, LockfileParseError, ResolvedPackage, WorkspaceManifest } from "@effected/lockfiles";
6
6
  import { Package } from "@effected/package-json";
7
7
  import { ChildProcessSpawner } from "effect/unstable/process";
8
- //#region src/CatalogAssemblyError.d.ts
9
- declare const CatalogAssemblyError_base: Schema.Class<CatalogAssemblyError, Schema.TaggedStruct<"CatalogAssemblyError", {
10
- /**
11
- * Which input failed: `manifest` for a file-level or top-level shape problem,
12
- * `catalog` for a malformed catalog block or the double-default duplication,
13
- * `hooks` for a config-dependency `pnpmfile.cjs` load or replay failure.
14
- */
15
- readonly source: Schema.Literals<readonly ["manifest", "catalog", "hooks"]>;
16
- /** The file, the catalog name, or the config dependency name. */
17
- readonly path: Schema.String;
18
- /** The originating failure. */
19
- readonly cause: Schema.Defect;
20
- }>, import("effect/Cause").YieldableError>;
21
- /**
22
- * Raised when a workspace's catalogs cannot be assembled — a `pnpm-workspace.yaml`
23
- * that is unreadable or not valid YAML, a root `package.json` `workspaces` field
24
- * whose shape or catalog blocks are malformed in a way pnpm itself rejects
25
- * (including the default catalog declared twice), or a config dependency whose
26
- * `pnpmfile.cjs` cannot be loaded or replayed.
27
- *
28
- * @remarks
29
- * A *missing* `pnpm-workspace.yaml`, an *absent* `workspaces` field, or one
30
- * explicitly `null` is not an error: there is simply nothing to misread, so
31
- * assembly yields the empty set. The reader is otherwise **hard-fail by design** —
32
- * a silently-empty catalog read is the "every dependency looks newly added" bug,
33
- * because catalog output is load-bearing for snapshot diffing.
34
- *
35
- * @public
36
- */
37
- declare class CatalogAssemblyError extends CatalogAssemblyError_base {
38
- /** Renders the failing source into a one-line message. */
39
- get message(): string;
40
- }
41
- //#endregion
42
8
  //#region src/WorkspacePackage.d.ts
43
9
  declare const PublishConfig_base: Schema.Class<PublishConfig, Schema.Struct<{
44
10
  /** Scoped-package visibility. Its presence overrides `private`. */
@@ -129,6 +95,20 @@ declare const WorkspacePackage_base: Schema.Class<WorkspacePackage, Schema.Struc
129
95
  readonly optionalDependencies: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>>;
130
96
  /** The `publishConfig` block, when present. */
131
97
  readonly publishConfig: Schema.optionalKey<typeof PublishConfig>;
98
+ /**
99
+ * The package's `package.json` as read — tolerant access to every field
100
+ * outside the typed discovery slice (`scripts`, `exports`, …) without a
101
+ * second file read.
102
+ *
103
+ * @remarks
104
+ * Values are `unknown` and exactly what discovery parsed; nothing here is
105
+ * validated beyond being a record. For the strict typed model use
106
+ * `manifest()`, which deliberately **re-reads** the file — a point-in-time
107
+ * refresh this captured record cannot provide. Defaults to `{}` for
108
+ * construction sites and previously-serialized values that predate the
109
+ * field.
110
+ */
111
+ readonly manifestRecord: Schema.withConstructorDefault<Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.Unknown>, never>>;
132
112
  }>, {}>;
133
113
  /**
134
114
  * A single package inside a workspace: the discovery-relevant slice of its
@@ -648,7 +628,7 @@ declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
648
628
  * (in process, no subprocess) and replays its `updateConfig` hook over the
649
629
  * seed, in declaration order. A dependency without a `pnpmfile.cjs` contributes
650
630
  * nothing; a dependency whose file fails to load or replay fails typed with a
651
- * `hooks`-source {@link CatalogAssemblyError}, never a silent skip.
631
+ * `hooks`-source `CatalogAssemblyError`, never a silent skip.
652
632
  *
653
633
  * @remarks
654
634
  * Node-coupled by design — the `node:fs` / `node:path` / `node:url` imports are
@@ -1107,7 +1087,7 @@ declare class CatalogSet extends CatalogSet_base {
1107
1087
  * load-bearing for: a present-but-malformed `workspaces` shape (a number, a
1108
1088
  * string, an object with a malformed `packages` / `catalog` / `catalogs`), or
1109
1089
  * the default catalog declared twice — once as `workspaces.catalog` and again
1110
- * as `workspaces.catalogs.default` — fails with {@link CatalogAssemblyError}
1090
+ * as `workspaces.catalogs.default` — fails with `CatalogAssemblyError`
1111
1091
  * naming what was wrong. An absent `workspaces` field, one explicitly `null`,
1112
1092
  * or the plain array form (npm/yarn patterns, which carry no catalogs) yields
1113
1093
  * the empty set. Presence is checked structurally, so an explicitly-declared
@@ -1190,7 +1170,7 @@ declare const WorkspaceCatalogs_base: Context.ServiceClass<WorkspaceCatalogs, "@
1190
1170
  * within a catalog. An unreadable or absent lockfile degrades to no lockfile
1191
1171
  * catalogs rather than failing (the inline declaration is the source of truth, the
1192
1172
  * lockfile a record of what was installed); a malformed inline source **fails
1193
- * typed** with {@link CatalogAssemblyError}, because a silently-empty catalog read
1173
+ * typed** with `CatalogAssemblyError`, because a silently-empty catalog read
1194
1174
  * is the "every dependency looks newly added" bug.
1195
1175
  *
1196
1176
  * Assembly is deferred to the first call and memoized success-only, matching
@@ -1229,9 +1209,12 @@ declare class WorkspaceCatalogs extends WorkspaceCatalogs_base {
1229
1209
  *
1230
1210
  * @remarks
1231
1211
  * `rangeOf` returns `Option.none()` for a dependency no catalog declares, per
1232
- * the contract's convention; `DependencyResolutionError` is reserved for a
1233
- * failure of the resolution *mechanism*an unfindable workspace root, an
1234
- * unreadable or malformed `pnpm-workspace.yaml`.
1212
+ * the contract's convention. A failed catalog *assembly* — an unreadable or
1213
+ * malformed `pnpm-workspace.yaml`, a broken config-dependency hook passes
1214
+ * through **typed** as the contract's `CatalogAssemblyError` (it used to be
1215
+ * folded into `DependencyResolutionError`'s defect `cause`, which forced
1216
+ * consumers to `_tag`-sniff `unknown`); only the remaining mechanism failure,
1217
+ * an unfindable workspace root, is wrapped as `DependencyResolutionError`.
1235
1218
  */
1236
1219
  static readonly catalogResolver: Layer.Layer<CatalogResolver, never, WorkspaceCatalogs>;
1237
1220
  }
@@ -1342,6 +1325,12 @@ declare class WorkspaceStateSnapshot extends WorkspaceStateSnapshot_base {
1342
1325
  * THIS snapshot's catalog set — so code written to the contract resolves
1343
1326
  * `catalog:` specifiers as of this ref. Built once per instance and cached, so
1344
1327
  * it memoizes by reference.
1328
+ *
1329
+ * @remarks
1330
+ * The contract's error channel (`CatalogAssemblyError` /
1331
+ * `DependencyResolutionError`) is satisfied vacuously: a snapshot's catalogs
1332
+ * were already assembled when it was captured, so this resolver is total —
1333
+ * `rangeOf` never fails.
1345
1334
  */
1346
1335
  get catalogResolver(): Layer.Layer<CatalogResolver>;
1347
1336
  /**
@@ -1365,7 +1354,7 @@ declare class WorkspaceStateSnapshot extends WorkspaceStateSnapshot_base {
1365
1354
  * filesystem, so no `WorkspaceDiscoveryError` / `WorkspacePatternError` appears —
1366
1355
  * and a malformed *lockfile* at the ref degrades to no catalogs (a lockfile is a
1367
1356
  * record, not a source of truth), so only the inline source raises
1368
- * {@link CatalogAssemblyError}.
1357
+ * `CatalogAssemblyError`.
1369
1358
  *
1370
1359
  * @public
1371
1360
  */
@@ -1479,39 +1468,148 @@ declare const Workspaces: {
1479
1468
  readonly layer: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path>;
1480
1469
  readonly layerWithConfigDependencies: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path>;
1481
1470
  readonly layerWithGit: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices | ChangeDetector | WorkspaceSnapshots | Git, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
1471
+ readonly resolveManifest: (manifest: Manifest, options?: WorkspacesOptions) => Effect.Effect<Manifest, CatalogAssemblyError | DependencyResolutionError | UnresolvedDependencyError, FileSystem.FileSystem | Path.Path>;
1472
+ readonly resolverLayer: (options?: WorkspacesOptions) => Layer.Layer<CatalogResolver | WorkspaceResolver, never, FileSystem.FileSystem | Path.Path>;
1482
1473
  readonly resolvers: Layer.Layer<CatalogResolver | WorkspaceResolver, never, WorkspaceCatalogs | WorkspaceDiscovery>;
1483
1474
  };
1484
1475
  //#endregion
1485
1476
  //#region src/WorkspacesSync.d.ts
1486
1477
  /**
1487
- * The nearest workspace root at or above `cwd`, or `null`.
1478
+ * The synchronous file operations the sync entry points need, supplied by the
1479
+ * consumer. Node's built-ins satisfy it directly:
1480
+ *
1481
+ * ```ts
1482
+ * import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1483
+ *
1484
+ * const fileSystem: SyncFileSystem = {
1485
+ * exists: existsSync,
1486
+ * readFile: (p) => readFileSync(p, "utf8"),
1487
+ * readDirectory: (p) => readdirSync(p),
1488
+ * isDirectory: (p) => statSync(p).isDirectory(),
1489
+ * };
1490
+ * ```
1491
+ *
1492
+ * `exists` must return a boolean and not throw (Node's `existsSync` never
1493
+ * does). The other three may throw — `statSync` on a missing path, a
1494
+ * permission error mid-read — and every throw is absorbed into the documented
1495
+ * degraded-skip semantics: a throwing `readFile` reads as an unusable
1496
+ * manifest, a throwing `readDirectory` as an unreadable directory, a throwing
1497
+ * `isDirectory` as "not a directory". Nothing propagates.
1498
+ *
1499
+ * @public
1500
+ */
1501
+ interface SyncFileSystem {
1502
+ /** Whether a file or directory exists at `path`. Must not throw. */
1503
+ readonly exists: (path: string) => boolean;
1504
+ /** Read the file at `path` as text. May throw; a throw degrades to a skip. */
1505
+ readonly readFile: (path: string) => string;
1506
+ /** The entry names inside the directory at `path`. May throw; a throw skips the directory. */
1507
+ readonly readDirectory: (path: string) => ReadonlyArray<string>;
1508
+ /** Whether `path` is a directory. May throw; a throw reads as `false`. */
1509
+ readonly isDirectory: (path: string) => boolean;
1510
+ }
1511
+ /**
1512
+ * The synchronous path operations the sync entry points need, supplied by the
1513
+ * consumer. Deliberately a structural subset of `node:path`, so the built-in
1514
+ * module (and its `win32` / `posix` variants, or a Bun / Deno equivalent)
1515
+ * satisfies it verbatim:
1516
+ *
1517
+ * ```ts
1518
+ * import * as path from "node:path";
1519
+ *
1520
+ * const options: WorkspacesSyncOptions = {
1521
+ * fileSystem: { exists: existsSync, readFile: (p) => readFileSync(p, "utf8"), readDirectory: (p) => readdirSync(p), isDirectory: (p) => statSync(p).isDirectory() },
1522
+ * path, // node:path IS a SyncPath
1523
+ * };
1524
+ * ```
1525
+ *
1526
+ * These operations shape only the ABSOLUTE paths handed back to the consumer
1527
+ * (and to its own `fileSystem`); workspace-relative pattern matching is POSIX
1528
+ * by the `packages:` contract and never routes through here. Windows
1529
+ * correctness comes from supplying a win32-appropriate implementation, not
1530
+ * from anything in this module.
1531
+ *
1532
+ * @public
1533
+ */
1534
+ interface SyncPath {
1535
+ /** Join segments with the implementation's separator (like `path.join`). */
1536
+ readonly join: (...segments: ReadonlyArray<string>) => string;
1537
+ /** The directory portion of `p` (like `path.dirname`). */
1538
+ readonly dirname: (p: string) => string;
1539
+ /** Resolve segments to an absolute path (rightmost-wins, like `path.resolve`). */
1540
+ readonly resolve: (...segments: ReadonlyArray<string>) => string;
1541
+ }
1542
+ /**
1543
+ * The consumer-supplied operations backing one sync call: the file operations
1544
+ * and the path implementation. Both are required — this package never imports
1545
+ * `node:*` and never assumes posix, so the platform binding is entirely the
1546
+ * caller's.
1547
+ *
1548
+ * @public
1549
+ */
1550
+ interface WorkspacesSyncOptions {
1551
+ /** The synchronous file operations (Node: `existsSync` / `readFileSync` / `readdirSync` / `statSync`). */
1552
+ readonly fileSystem: SyncFileSystem;
1553
+ /** The synchronous path implementation (Node: the `node:path` module itself). */
1554
+ readonly path: SyncPath;
1555
+ }
1556
+ /**
1557
+ * Options for {@link findWorkspaceRootSync}: the required consumer-supplied
1558
+ * operations plus where to start the ascent.
1559
+ *
1560
+ * @public
1561
+ */
1562
+ interface FindWorkspaceRootSyncOptions extends WorkspacesSyncOptions {
1563
+ /**
1564
+ * Where to start the ascent, matching the Effect layers' `{ cwd }` option.
1565
+ *
1566
+ * @defaultValue `process.cwd()`, read lazily per call.
1567
+ */
1568
+ readonly cwd?: string;
1569
+ }
1570
+ /**
1571
+ * The nearest workspace root at or above `options.cwd`, or `null`.
1488
1572
  *
1489
1573
  * @remarks
1490
- * **Synchronous and Node-only.** The Effect surface is `WorkspaceRoot`; reach
1491
- * for this one only where you genuinely cannot run an Effect — a Vitest config
1492
- * being the motivating case.
1574
+ * **Synchronous.** The Effect surface is `WorkspaceRoot`; reach for this one
1575
+ * only where you genuinely cannot run an Effect — a Vitest config being the
1576
+ * motivating case. The file and path operations are the caller's
1577
+ * ({@link FindWorkspaceRootSyncOptions}); this module imports no `node:*` and
1578
+ * assumes no posix.
1493
1579
  *
1494
1580
  * Markers match the async service exactly: a `pnpm-workspace.yaml`, or a
1495
1581
  * `package.json` carrying a `workspaces` field.
1496
1582
  *
1497
- * @param cwd - Where to start the ascent. Defaults to `process.cwd()`.
1583
+ * @param options - The consumer-supplied file and path operations, plus the
1584
+ * optional `cwd` to start from (defaulting to `process.cwd()`).
1498
1585
  *
1499
1586
  * @example
1500
1587
  * ```ts
1588
+ * import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1589
+ * import * as path from "node:path";
1501
1590
  * import { findWorkspaceRootSync } from "@effected/workspaces";
1502
1591
  *
1503
- * const root = findWorkspaceRootSync();
1592
+ * const root = findWorkspaceRootSync({
1593
+ * fileSystem: {
1594
+ * exists: existsSync,
1595
+ * readFile: (p) => readFileSync(p, "utf8"),
1596
+ * readDirectory: (p) => readdirSync(p),
1597
+ * isDirectory: (p) => statSync(p).isDirectory(),
1598
+ * },
1599
+ * path,
1600
+ * });
1504
1601
  * ```
1505
1602
  *
1506
1603
  * @public
1507
1604
  */
1508
- declare const findWorkspaceRootSync: (cwd?: string) => string | null;
1605
+ declare const findWorkspaceRootSync: (options: FindWorkspaceRootSyncOptions) => string | null;
1509
1606
  /**
1510
- * Options for {@link getWorkspacePackagesSync}.
1607
+ * Options for {@link getWorkspacePackagesSync}: the required consumer-supplied
1608
+ * operations plus the traversal bound.
1511
1609
  *
1512
1610
  * @public
1513
1611
  */
1514
- interface GetWorkspacePackagesSyncOptions {
1612
+ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
1515
1613
  /**
1516
1614
  * Descent cap below a wildcard's enumeration prefix, mirroring
1517
1615
  * `WorkspaceDiscovery.layer({ maxDepth })`.
@@ -1524,7 +1622,10 @@ interface GetWorkspacePackagesSyncOptions {
1524
1622
  * Every workspace package under `root`, root package first.
1525
1623
  *
1526
1624
  * @remarks
1527
- * **Synchronous and Node-only.** The Effect surface is `WorkspaceDiscovery`.
1625
+ * **Synchronous.** The Effect surface is `WorkspaceDiscovery`. The file and
1626
+ * path operations are the caller's ({@link GetWorkspacePackagesSyncOptions}
1627
+ * extends {@link WorkspacesSyncOptions}); this module imports no `node:*` and
1628
+ * assumes no posix.
1528
1629
  *
1529
1630
  * Pattern semantics are not merely "shared" with the Effect enumerator — both
1530
1631
  * drive the **same traversal state machine** (`internal/traverse.ts`), so the
@@ -1541,19 +1642,31 @@ interface GetWorkspacePackagesSyncOptions {
1541
1642
  * the enumerator's defect.
1542
1643
  *
1543
1644
  * @param root - The workspace root, from {@link findWorkspaceRootSync}.
1544
- * @param options - Traversal bounds; see {@link GetWorkspacePackagesSyncOptions}.
1645
+ * @param options - The consumer-supplied operations and traversal bounds; see
1646
+ * {@link GetWorkspacePackagesSyncOptions}.
1545
1647
  *
1546
1648
  * @example
1547
1649
  * ```ts
1650
+ * import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1651
+ * import * as path from "node:path";
1548
1652
  * import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
1549
1653
  *
1550
- * const root = findWorkspaceRootSync();
1551
- * const packages = root === null ? [] : getWorkspacePackagesSync(root);
1654
+ * const ops = {
1655
+ * fileSystem: {
1656
+ * exists: existsSync,
1657
+ * readFile: (p: string) => readFileSync(p, "utf8"),
1658
+ * readDirectory: (p: string) => readdirSync(p),
1659
+ * isDirectory: (p: string) => statSync(p).isDirectory(),
1660
+ * },
1661
+ * path,
1662
+ * };
1663
+ * const root = findWorkspaceRootSync(ops);
1664
+ * const packages = root === null ? [] : getWorkspacePackagesSync(root, ops);
1552
1665
  * ```
1553
1666
  *
1554
1667
  * @public
1555
1668
  */
1556
- declare const getWorkspacePackagesSync: (root: string, options?: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
1669
+ declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
1557
1670
  //#endregion
1558
- export { CatalogAssemblyError, type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type GetWorkspacePackagesSyncOptions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, findWorkspaceRootSync, getWorkspacePackagesSync };
1671
+ export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootSyncOptions, type GetWorkspacePackagesSyncOptions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type SyncFileSystem, type SyncPath, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, findWorkspaceRootSync, getWorkspacePackagesSync };
1559
1672
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { CatalogAssemblyError } from "./CatalogAssemblyError.js";
2
1
  import { PublishConfig, WorkspaceManifestError, WorkspacePackage } from "./WorkspacePackage.js";
3
2
  import { WORKSPACE_MARKERS, WorkspaceRoot, WorkspaceRootNotFoundError } from "./WorkspaceRoot.js";
4
3
  import { PackageNotFoundError, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspacePatternError } from "./WorkspaceDiscovery.js";
@@ -14,4 +13,4 @@ import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
14
13
  import { Workspaces } from "./Workspaces.js";
15
14
  import { findWorkspaceRootSync, getWorkspacePackagesSync } from "./WorkspacesSync.js";
16
15
 
17
- export { CatalogAssemblyError, CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, PackageManagerDetectionError, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, findWorkspaceRootSync, getWorkspacePackagesSync };
16
+ export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, PackageManagerDetectionError, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, findWorkspaceRootSync, getWorkspacePackagesSync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": [
@@ -42,9 +42,9 @@
42
42
  "dependencies": {
43
43
  "@effected/git": "0.1.0",
44
44
  "@effected/glob": "0.1.0",
45
- "@effected/lockfiles": "0.1.0",
46
- "@effected/npm": "0.1.0",
47
- "@effected/package-json": "0.1.0",
45
+ "@effected/lockfiles": "0.1.1",
46
+ "@effected/npm": "0.2.0",
47
+ "@effected/package-json": "0.2.0",
48
48
  "@effected/walker": "0.1.0",
49
49
  "@effected/yaml": "0.1.0",
50
50
  "@pnpm/catalogs.config": "^1100.0.0",
@@ -1,43 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- //#region src/CatalogAssemblyError.ts
4
- /**
5
- * Raised when a workspace's catalogs cannot be assembled — a `pnpm-workspace.yaml`
6
- * that is unreadable or not valid YAML, a root `package.json` `workspaces` field
7
- * whose shape or catalog blocks are malformed in a way pnpm itself rejects
8
- * (including the default catalog declared twice), or a config dependency whose
9
- * `pnpmfile.cjs` cannot be loaded or replayed.
10
- *
11
- * @remarks
12
- * A *missing* `pnpm-workspace.yaml`, an *absent* `workspaces` field, or one
13
- * explicitly `null` is not an error: there is simply nothing to misread, so
14
- * assembly yields the empty set. The reader is otherwise **hard-fail by design** —
15
- * a silently-empty catalog read is the "every dependency looks newly added" bug,
16
- * because catalog output is load-bearing for snapshot diffing.
17
- *
18
- * @public
19
- */
20
- var CatalogAssemblyError = class extends Schema.TaggedErrorClass()("CatalogAssemblyError", {
21
- /**
22
- * Which input failed: `manifest` for a file-level or top-level shape problem,
23
- * `catalog` for a malformed catalog block or the double-default duplication,
24
- * `hooks` for a config-dependency `pnpmfile.cjs` load or replay failure.
25
- */
26
- source: Schema.Literals([
27
- "manifest",
28
- "catalog",
29
- "hooks"
30
- ]),
31
- /** The file, the catalog name, or the config dependency name. */
32
- path: Schema.String,
33
- /** The originating failure. */
34
- cause: Schema.Defect()
35
- }) {
36
- /** Renders the failing source into a one-line message. */
37
- get message() {
38
- return `Failed to assemble catalogs from ${this.source} ${this.path}`;
39
- }
40
- };
41
-
42
- //#endregion
43
- export { CatalogAssemblyError };