# @effected/workspaces

[![npm](https://img.shields.io/npm/v/@effected%2Fworkspaces?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/workspaces)
[![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
[![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
[![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)

Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile, check it for unsatisfied peer dependencies and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.

> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
> development against a single pinned Effect v4 prerelease. Packages graduate to
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
> exactly the ones the kit is built and tested against, install
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
>
> **Stability: unstable.** This package's API surface is not yet considered
> complete and may change across `0.x` releases. Pin an exact version — even a
> package marked *stable* before `1.0.0` can introduce a breaking change by
> accident, and an exact pin turns that into a type-check error rather than a
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).

## Why @effected/workspaces

Monorepo tooling keeps re-deriving the same facts: where the root is, which directories are packages, what depends on what, what a `catalog:` specifier means and which packages a change affects. Each tool re-derives them slightly differently, and the differences show up as bugs. This package answers those questions once.

Discovery is honest about what a glob means. A `packages/**` pattern finds packages nested more than one level deep, because the enumerator does a bounded descent rather than the one-level approximation that a trailing-`**` rewrite quietly turns it into — and a package that goes undiscovered with no diagnostic is the worst kind of wrong, because an empty result is indistinguishable from a legitimately empty workspace. The same discipline runs through the error model: a malformed `package.json`, an unenumerable pattern, a missing lockfile and a failed git command all fail through the typed channel with structured fields, while a developer wiring mistake (an uncompilable glob literal, a fractional `maxDepth`) stays a defect. The typed channel is exactly the set of things a caller can branch on.

Git runs through `@effected/git`'s `Git` service rather than a hard-coded subprocess call, so change detection is testable with no repository on disk and portable to a runtime that spawns processes differently. And where `@effected/npm` declares the `CatalogResolver` and `WorkspaceResolver` seams — contracts that `@effected/package-json` consumes but no pure package can fill — this is the package that fills them.

## Install

```bash
npm install @effected/workspaces effect
```

```bash
pnpm add @effected/workspaces effect
```

Requires Node.js >=24.11.0. `effect` v4 is a peer dependency. You provide a `FileSystem` and `Path` implementation at the edge — `@effect/platform-node` or `@effect/platform-bun`.

All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.

pnpm's catalog semantics come from pnpm's own `@pnpm/catalogs.*` packages, which install as regular dependencies. Reimplementing them would mean owning a moving spec with no oracle, so they are used directly and confined to a single internal module.

## Quick start

```ts
import { NodeFileSystem, NodePath } from "@effect/platform-node";
import { DependencyGraph, WorkspaceDiscovery, Workspaces } from "@effected/workspaces";
import { Effect, Layer } from "effect";

// Bind the layer to a const: layers memoize by reference, so calling
// Workspaces.layer() twice builds the whole stack twice.
const Platform = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
const WorkspacesLayer = Workspaces.layer().pipe(Layer.provide(Platform));

const program = Effect.gen(function* () {
  const discovery = yield* WorkspaceDiscovery;

  const packages = yield* discovery.listPackages();
  const graph = DependencyGraph.make({ packages });

  // Parallel build tiers: level 0 depends on nothing in the workspace,
  // level n depends only on the levels below it.
  return yield* graph.levels();
});

Effect.runPromise(program.pipe(Effect.provide(WorkspacesLayer))).then(console.log);
// [ [ ...names with no workspace dependencies ], [ ...names that depend only on level 0 ], ... ]
```

`DependencyGraph` is a value class, not a service: build it from packages you already have. A cycle fails with `CyclicDependencyError`, whose `cycle` field names the packages actually in the cycle — the members of the strongly-connected components — and not the ones merely stalled behind it, which is the difference between a fix list and a suspect list.

`toMermaid()` renders the same graph for a job summary, an issue or a design doc. It is total, deterministic (nodes and edges both in sorted order) and safe for scoped names, which appear only inside quoted labels:

```ts
console.log(graph.toMermaid());
// flowchart TD
//   0["@acme/app"]
//   1["@acme/utils"]
//   0 --> 1
```

## Change detection

`ChangeDetector` offers three depths of analysis on one service — `changedFiles` (raw paths from a git range), `changedPackages` (the packages owning them) and `affectedPackages` (the transitive blast radius through the dependency graph).

```ts
import { NodeServices } from "@effect/platform-node";
import { ChangeDetectionOptions, ChangeDetector, Workspaces } from "@effected/workspaces";
import { Effect, Layer } from "effect";

// layerWithGit runs ChangeDetector over @effected/git's Git service; NodeServices
// provides the ChildProcessSpawner it needs, alongside FileSystem and Path.
const WorkspacesLayer = Workspaces.layerWithGit().pipe(Layer.provide(NodeServices.layer));

const program = Effect.gen(function* () {
  const detector = yield* ChangeDetector;
  const affected = yield* detector.affectedPackages(ChangeDetectionOptions.make({ base: "origin/main" }));
  return affected.map((pkg) => pkg.name);
});

Effect.runPromise(program.pipe(Effect.provide(WorkspacesLayer))).then(console.log);
// [ ...names of packages the range touched, plus everything downstream of them ]
```

Git is a separate layer rather than a flag, because the extra requirement is a subprocess: a consumer that never detects changes should not have to be able to spawn one. A test provides the `Git` service with a `Layer.succeed` stub and needs no repository at all.

## pnpm catalogs

`WorkspaceCatalogs` assembles a workspace's catalogs with pnpm's precedence (the lockfile's record first, the inline `pnpm-workspace.yaml` declaration wins) and resolves `catalog:` specifiers against the result.

It also supplies the real implementations of `@effected/npm`'s `CatalogResolver` and `WorkspaceResolver` contracts — the seams `@effected/package-json` reads through, which without a workspace under them can only answer `Option.none()`. Provide `Workspaces.resolvers` and `Package.resolve` rewrites `catalog:` and `workspace:` specifiers to concrete ranges:

```ts
import { Workspaces } from "@effected/workspaces";
import { Layer } from "effect";

const WorkspacesLayer = Workspaces.layer();
const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
// Layer<CatalogResolver | WorkspaceResolver, never, FileSystem | Path>
```

`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.

For whole manifests, `Workspaces.resolveManifest` is the one-shot path over `@effected/npm`'s tolerant `Manifest` model:

```ts
import { Manifest } from "@effected/npm";
import { Workspaces } from "@effected/workspaces";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
  const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
  return resolved.toRecord();
});
// needsResolution is pure — checking it first skips catalog assembly entirely
// when no dependency field carries a catalog: or workspace: specifier
```

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.

## Peer dependency checking

`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.

```ts
import { LockfileReader, PeerCheck, WorkspaceCatalogs } from "@effected/workspaces";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const reader = yield* LockfileReader;
  const catalogs = yield* WorkspaceCatalogs;

  const lockfile = yield* reader.read();
  // Presence of the key is the assertion — see below.
  const report = PeerCheck.run(lockfile, { peerDependencyRules: yield* catalogs.peerDependencyRules() });

  return {
    clean:
      report.supported &&
      report.unresolvedImporters.length === 0 &&
      report.unverified.length === 0 &&
      report.required.length === 0,
    required: report.required.length,
  };
});
// { clean: whether the workspace is proven clean, required: count of non-optional findings }
```

**An empty `unsatisfied` is not the same as clean**, and reading it that way is the mistake this report's shape exists to prevent. Three other fields carry the difference between "nothing is wrong" and "nothing was checked", and a gate must read all four:

- `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.
- `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.
- `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.
- `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.

`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.

## The synchronous escape hatch

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. On Node you do not have to write them: the `@effected/workspaces/node-sync` subpath exports `nodeSyncOps`, the ready-made `node:fs` and `node:path` bindings, so adopting the sync path is one extra import.

```ts
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
import { nodeSyncOps } from "@effected/workspaces/node-sync";

const root = findWorkspaceRootSync(process.cwd(), nodeSyncOps);
const packages = root === null ? [] : getWorkspacePackagesSync(root, nodeSyncOps);
// root: the workspace root path, or null when none is found above the cwd
// packages: the discovered workspace packages, empty when there is no root
```

Both entry points take their path positionally, so the bag usually passes through verbatim; spread it to add `getWorkspacePackagesSync`'s traversal extras — `{ ...nodeSyncOps, maxDepth }`. The bindings are a separate subpath deliberately: the main entry imports nothing platform-shaped, and re-exporting them from it would drag `node:*` into every consumer, including the ones supplying their own operations. `nodePath` is the running platform's `node:path`, so on Windows the paths handed back are win32 paths.

Write the operations yourself when Node's built-ins are not the platform you mean — a Bun or Deno binding, a test fake, or `node:path/win32` to pin a dialect rather than follow the running platform. Each one is a one-liner:

```ts
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import * as path from "node:path";
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";

const options = {
  fileSystem: {
    exists: existsSync,
    readFile: (p: string) => readFileSync(p, "utf8"),
    readDirectory: (p: string) => readdirSync(p),
    isDirectory: (p: string) => statSync(p).isDirectory(),
  },
  path, // node:path satisfies SyncPath verbatim
};

const root = findWorkspaceRootSync(process.cwd(), options);
const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
// root: the workspace root path, or null when none is found above the cwd
// packages: the discovered workspace packages, empty when there is no root
```

Those four operations are the whole requirement. A fifth, `readDirectoryWithTypes`, is optional: supply it and package enumeration reads a directory's entries and their types in one call instead of a `readDirectory` plus an `isDirectory` per entry, which on a large workspace is a syscall per file. `nodeFileSystem` already implements it over `readdirSync(p, { withFileTypes: true })`, so the `node-sync` bindings get the fast path for free. Omit it and enumeration falls back to the four required operations with identical results — a cost optimization, never a behavior switch.

Each entry reports `name`, `isDirectory` and `isSymbolicLink` as a `SyncDirectoryEntry`, which Node's `Dirent` satisfies once its predicate methods are called. The link flag is load-bearing: a `Dirent` describes the entry itself, so a symlink pointing at a directory reports `isDirectory: false`, while the `stat`-based path resolves the link and calls the same entry a directory. Enumeration re-resolves links through `isDirectory` rather than trusting the flag, which is what keeps a workspace with symlinked packages discovered identically on both paths.

```ts
import { readdirSync } from "node:fs";
import type { SyncDirectoryEntry } from "@effected/workspaces";

const readDirectoryWithTypes = (p: string): ReadonlyArray<SyncDirectoryEntry> =>
  readdirSync(p, { withFileTypes: true }).map((entry) => ({
    name: entry.name,
    isDirectory: entry.isDirectory(),
    isSymbolicLink: entry.isSymbolicLink(),
  }));
// pass alongside the four required operations: { ...options.fileSystem, readDirectoryWithTypes }
```

For a test fake, `@effected/memfs`' `MemoryFileSystem.syncFileSystem(volume)` satisfies `SyncFileSystem` structurally — neither package imports the other — so a config-time discovery path can be exercised against a virtual workspace with nothing on disk.

Windows correctness is therefore the operations you pass, and nothing else. 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.

## Error handling

Every failure is a `Schema.TaggedError` with structured fields you can branch on, not a prose string:

```ts
import { WorkspaceDiscovery, WorkspacePatternError } from "@effected/workspaces";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const discovery = yield* WorkspaceDiscovery;
  return yield* discovery.listPackages();
}).pipe(
  Effect.catchTag("WorkspacePatternError", (error: WorkspacePatternError) =>
    // kind: "missingBaseDir" | "uncompilable" | "depthExceeded" | "budgetExceeded"
    Effect.logError(`pattern ${error.pattern} failed: ${error.kind}`).pipe(Effect.as([])),
  ),
);
```

`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`.

## Testing

Every service here can be replaced with `Layer.succeed` and a hand-built value, and `WorkspaceDiscovery` ships that pattern ready-made: `WorkspaceDiscovery.layerTest(overrides)` provides an in-memory double where a test stubs only the methods it exercises. The defaults model an empty workspace, and the derived methods run over the effective `listPackages`, so stubbing that one method keeps `getPackage`, `importerMap` and `resolveFile` answering consistently:

```ts
import { WorkspaceDiscovery, WorkspacePackage } from "@effected/workspaces";
import { Effect } from "effect";

// Bind to a const — layers memoize by reference.
const TestDiscovery = WorkspaceDiscovery.layerTest({
  listPackages: () =>
    Effect.succeed([
      WorkspacePackage.make({
        name: "@my-org/utils",
        version: "1.0.0",
        path: "/repo/packages/utils",
        packageJsonPath: "/repo/packages/utils/package.json",
        relativePath: "packages/utils",
      }),
    ]),
});
// program.pipe(Effect.provide(TestDiscovery))
```

A name miss in the derived `getPackage` fails with the service's own typed `PackageNotFoundError`, exactly as the live implementation does. Two deliberate edges: `info()` has no honest default (a fabricated root path would leak into consumer path logic), so it dies with an explanatory defect unless stubbed, and the derived file-ownership methods assume POSIX paths, so pass your own `resolveFile` for win32 fixtures. `WorkspaceDiscovery.makeTest(overrides)` returns the bare service shape when you want the double without a layer.

## Features

- `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.
- `Workspaces.layerWithConfigDependencies` / `Workspaces.layerWithConfigDependenciesSubprocess` — opt in to replaying a pnpm config dependency's pnpmfile hooks, which is what lets catalogs and `releaseAgeGate()` see the entries a hook injects. The default layer runs no config-dependency code at all. The two spellings differ only in where the replay happens: in process, or in a `node` child process for a consumer whose code is bundled (a GitHub Action, say), where the in-process form's computed dynamic import cannot survive the bundler. The subprocess form asks for core's `ChildProcessSpawner`; `WorkspaceCatalogs` carries the same pair.
- `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.
- `WorkspaceRoot` — root discovery from a `cwd`, over `WORKSPACE_MARKERS`.
- `WorkspaceDiscovery` — package enumeration with a bounded descent for segment-crossing `packages/**` patterns, per-package lookup and the `makeTest` / `layerTest` in-memory test doubles.
- `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`.
- `DependencyGraph` — a value class over discovered packages: `levels()` for parallel build tiers, the flattened topological order, `toMermaid()` for a deterministic Mermaid `flowchart TD` of the whole graph, and `CyclicDependencyError` — naming the cycle's actual members — when there is no order.
- `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field.
- `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages; `releaseAgeGate()` assembles the effective `@effected/npm` `ReleaseAgeGate` from inline `pnpm-workspace.yaml` release-age keys and replayed hook contributions, strictest-wins, in the same pass as the catalogs.
- `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
- `PeerCheck` — unsatisfied peer-dependency detection as a pure, total value over a parsed lockfile, with `UnsatisfiedPeer` and `PeerParent` as the report's rows. Read `supported`, `unresolvedImporters` and `unverified` alongside `unsatisfied`: an empty finding list is a clean bill of health only when those three say so.
- `NoPeerDependencyRules` / `PeerDependencyRules` — the effective pnpm suppression policy `WorkspaceCatalogs.peerDependencyRules()` assembles, and the "I assert none apply" value for callers that have checked.
- `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
- `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). No composite provides one: pick `PublishabilityDetector.layerNpm` (standard npm semantics) or `.layerNone` (nothing publishes) and provide it explicitly.
- `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
- `VersioningStrategy` — classify a workspace as `single`, `fixed-group` or `independent` from package names and fixed groups, or detect it live against `PublishabilityDetector`, and produce the release tags for a batch with `tagsFor`.
- `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
- `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own. `nodeFileSystem` implements the optional `readDirectoryWithTypes` fast path, so the bindings enumerate a workspace in one `readdirSync` per directory.

## License

[MIT](LICENSE)
